refactoring the api endpoints to be separate files to reduce context usage

This commit is contained in:
Cody Seibert
2025-12-14 17:53:21 -05:00
parent cdc8334d82
commit 6b30271441
121 changed files with 4281 additions and 2927 deletions

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<Feature>;
};
if (!projectPath || !feature) {
res
.status(400)
.json({ success: false, error: "projectPath and feature are required" });
return;
}
// Add project path to allowed paths
addAllowedPath(projectPath);
const created = await featureLoader.create(projectPath, feature);
res.json({ success: true, feature: created });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Update a feature
router.post("/update", async (req: Request, res: Response) => {
try {
const { projectPath, featureId, updates } = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
};
if (!projectPath || !featureId || !updates) {
res.status(400).json({
success: false,
error: "projectPath, featureId, and updates are required",
});
return;
}
const updated = await featureLoader.update(projectPath, featureId, updates);
res.json({ success: true, feature: updated });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Delete a feature
router.post("/delete", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
const success = await featureLoader.delete(projectPath, featureId);
res.json({ success });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get agent output for a feature
router.post("/agent-output", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
const content = await featureLoader.getAgentOutput(projectPath, featureId);
res.json({ success: true, content });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
try {
const { projectPath, feature } = req.body as {
projectPath: string;
feature: Partial<Feature>;
};
if (!projectPath || !feature) {
res
.status(400)
.json({
success: false,
error: "projectPath and feature are required",
});
return;
}
// Add project path to allowed paths
addAllowedPath(projectPath);
const created = await featureLoader.create(projectPath, feature);
res.json({ success: true, feature: created });
} catch (error) {
logError(error, "Create feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
try {
const { projectPath, featureId, updates } = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
};
if (!projectPath || !featureId || !updates) {
res.status(400).json({
success: false,
error: "projectPath, featureId, and updates are required",
});
return;
}
const updated = await featureLoader.update(
projectPath,
featureId,
updates
);
res.json({ success: true, feature: updated });
} catch (error) {
logError(error, "Update feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -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<string[]> => {
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<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".bmp": "image/bmp",
};
res.setHeader(
"Content-Type",
mimeTypes[ext] || "application/octet-stream"
);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(buffer);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// 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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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<string[]> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
try {
const { path: imagePath, projectPath } = req.query as {
path?: string;
projectPath?: string;
};
if (!imagePath) {
res.status(400).json({ success: false, error: "path is required" });
return;
}
// Resolve full path
const fullPath = path.isAbsolute(imagePath)
? imagePath
: projectPath
? path.join(projectPath, imagePath)
: imagePath;
// Check if file exists
try {
await fs.access(fullPath);
} catch {
res.status(404).json({ success: false, error: "Image not found" });
return;
}
// Read the file
const buffer = await fs.readFile(fullPath);
// Determine MIME type from extension
const ext = path.extname(fullPath).toLowerCase();
const mimeTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".bmp": "image/bmp",
};
res.setHeader(
"Content-Type",
mimeTypes[ext] || "application/octet-stream"
);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(buffer);
} catch (error) {
logError(error, "Serve image failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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;
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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;
}

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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,
},
});
};
}

View File

@@ -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",
});
};
}

View File

@@ -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<string, any> = {
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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
try {
// Get installation status from all providers
const statuses = await ProviderFactory.checkAllProviders();
const providers: Record<string, any> = {
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) });
}
};
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<string, string> = {};
// Helper to persist API keys to .env file
async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
const envPath = path.join(process.cwd(), ".env");
try {
let envContent = "";
try {
envContent = await fs.readFile(envPath, "utf-8");
} catch {
// .env file doesn't exist, we'll create it
}
// Parse existing env content
const lines = envContent.split("\n");
const keyRegex = new RegExp(`^${key}=`);
let found = false;
const newLines = lines.map((line) => {
if (keyRegex.test(line)) {
found = true;
return `${key}=${value}`;
}
return line;
});
if (!found) {
// Add the key at the end
newLines.push(`${key}=${value}`);
}
await fs.writeFile(envPath, newLines.join("\n"));
console.log(`[Setup] Persisted ${key} to .env file`);
} catch (error) {
console.error(`[Setup] Failed to persist ${key} to .env:`, error);
throw error;
}
}
export function createSetupRoutes(): Router {
const router = Router();
// 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;
}

View File

@@ -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<string, string> = {};
/**
* Helper to persist API keys to .env file
*/
export async function persistApiKeyToEnv(
key: string,
value: string
): Promise<void> {
const envPath = path.join(process.cwd(), ".env");
try {
let envContent = "";
try {
envContent = await fs.readFile(envPath, "utf-8");
} catch {
// .env file doesn't exist, we'll create it
}
// Parse existing env content
const lines = envContent.split("\n");
const keyRegex = new RegExp(`^${key}=`);
let found = false;
const newLines = lines.map((line) => {
if (keyRegex.test(line)) {
found = true;
return `${key}=${value}`;
}
return line;
});
if (!found) {
// Add the key at the end
newLines.push(`${key}=${value}`);
}
await fs.writeFile(envPath, newLines.join("\n"));
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);
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<string, string> = {
features: "Analyze this project and suggest new features that would add value.",
refactoring: "Analyze this project and identify refactoring opportunities.",
security: "Analyze this project for security vulnerabilities and suggest fixes.",
performance: "Analyze this project for performance issues and suggest optimizations.",
};
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
Look at the codebase and provide 3-5 concrete suggestions.
For each suggestion, provide:
1. A category (e.g., "User Experience", "Security", "Performance")
2. A clear description of what to implement
3. Concrete steps to implement it
4. Priority (1=high, 2=medium, 3=low)
5. Brief reasoning for why this would help
Format your response as JSON:
{
"suggestions": [
{
"id": "suggestion-123",
"category": "Category",
"description": "What to implement",
"steps": ["Step 1", "Step 2"],
"priority": 1,
"reasoning": "Why this helps"
}
]
}`;
events.emit("suggestions:event", {
type: "suggestions_progress",
content: `Starting ${suggestionType} analysis...\n`,
});
const options: Options = {
model: "claude-opus-4-5-20251101",
maxTurns: 5,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController,
};
const stream = query({ prompt, options });
let responseText = "";
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
events.emit("suggestions:event", {
type: "suggestions_progress",
content: block.text,
});
} else if (block.type === "tool_use") {
events.emit("suggestions:event", {
type: "suggestions_tool",
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
// Parse suggestions from response
try {
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
events.emit("suggestions:event", {
type: "suggestions_complete",
suggestions: parsed.suggestions.map((s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
})),
});
} else {
throw new Error("No valid JSON found in response");
}
} catch (error) {
// Return generic suggestions if parsing fails
events.emit("suggestions:event", {
type: "suggestions_complete",
suggestions: [
{
id: `suggestion-${Date.now()}-0`,
category: "Analysis",
description: "Review the AI analysis output for insights",
steps: ["Review the generated analysis"],
priority: 1,
reasoning: "The AI provided analysis but suggestions need manual review",
},
],
});
}
}

View File

@@ -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);
}

View File

@@ -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<void> {
const typePrompts: Record<string, string> = {
features:
"Analyze this project and suggest new features that would add value.",
refactoring: "Analyze this project and identify refactoring opportunities.",
security:
"Analyze this project for security vulnerabilities and suggest fixes.",
performance:
"Analyze this project for performance issues and suggest optimizations.",
};
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
Look at the codebase and provide 3-5 concrete suggestions.
For each suggestion, provide:
1. A category (e.g., "User Experience", "Security", "Performance")
2. A clear description of what to implement
3. Concrete steps to implement it
4. Priority (1=high, 2=medium, 3=low)
5. Brief reasoning for why this would help
Format your response as JSON:
{
"suggestions": [
{
"id": "suggestion-123",
"category": "Category",
"description": "What to implement",
"steps": ["Step 1", "Step 2"],
"priority": 1,
"reasoning": "Why this helps"
}
]
}`;
events.emit("suggestions:event", {
type: "suggestions_progress",
content: `Starting ${suggestionType} analysis...\n`,
});
const options: Options = {
model: "claude-opus-4-5-20251101",
maxTurns: 5,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController,
};
const stream = query({ prompt, options });
let responseText = "";
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
events.emit("suggestions:event", {
type: "suggestions_progress",
content: block.text,
});
} else if (block.type === "tool_use") {
events.emit("suggestions:event", {
type: "suggestions_tool",
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
// Parse suggestions from response
try {
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
events.emit("suggestions:event", {
type: "suggestions_complete",
suggestions: parsed.suggestions.map(
(s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
})
),
});
} else {
throw new Error("No valid JSON found in response");
}
} catch (error) {
// Return generic suggestions if parsing fails
events.emit("suggestions:event", {
type: "suggestions_complete",
suggestions: [
{
id: `suggestion-${Date.now()}-0`,
category: "Analysis",
description: "Review the AI analysis output for insights",
steps: ["Review the generated analysis"],
priority: 1,
reasoning:
"The AI provided analysis but suggestions need manual review",
},
],
});
}
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
try {
res.json({ success: true, isRunning });
} catch (error) {
logError(error, "Get status failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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;
};
}

View File

@@ -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<string, { createdAt: Date; expiresAt: Date }> = 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;
}

View File

@@ -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<string, { createdAt: Date; expiresAt: Date }> =
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";
}

View File

@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More