mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge branch 'main' into fix/sandbox-cloud-storage-compatibility
This commit is contained in:
@@ -24,7 +24,7 @@ ALLOWED_ROOT_DIRECTORY=
|
||||
|
||||
# CORS origin - which domains can access the API
|
||||
# Use "*" for development, set specific origin for production
|
||||
CORS_ORIGIN=*
|
||||
CORS_ORIGIN=http://localhost:3007
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Server
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# Automaker Backend Server
|
||||
# Multi-stage build for minimal production image
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and scripts needed for postinstall
|
||||
COPY package*.json ./
|
||||
COPY apps/server/package*.json ./apps/server/
|
||||
COPY scripts ./scripts
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --workspace=apps/server
|
||||
|
||||
# Copy source
|
||||
COPY apps/server ./apps/server
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build --workspace=apps/server
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install git, curl, and GitHub CLI (pinned version for reproducible builds)
|
||||
RUN apk add --no-cache git curl && \
|
||||
GH_VERSION="2.63.2" && \
|
||||
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o gh.tar.gz && \
|
||||
tar -xzf gh.tar.gz && \
|
||||
mv "gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh && \
|
||||
rm -rf gh.tar.gz "gh_${GH_VERSION}_linux_amd64"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S automaker && \
|
||||
adduser -S automaker -u 1001
|
||||
|
||||
# Copy built files and production dependencies
|
||||
COPY --from=builder /app/apps/server/dist ./dist
|
||||
COPY --from=builder /app/apps/server/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /data && chown automaker:automaker /data
|
||||
|
||||
# Switch to non-root user
|
||||
USER automaker
|
||||
|
||||
# Environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3008
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3008/api/health || exit 1
|
||||
|
||||
# Start server
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.2",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:test": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/",
|
||||
@@ -20,31 +24,35 @@
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
|
||||
"@automaker/dependency-resolver": "^1.0.0",
|
||||
"@automaker/git-utils": "^1.0.0",
|
||||
"@automaker/model-resolver": "^1.0.0",
|
||||
"@automaker/platform": "^1.0.0",
|
||||
"@automaker/prompts": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"morgan": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/git-utils": "1.0.0",
|
||||
"@automaker/model-resolver": "1.0.0",
|
||||
"@automaker/platform": "1.0.0",
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.25.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
"express": "5.2.1",
|
||||
"morgan": "1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.3"
|
||||
"ws": "8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^22",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.16"
|
||||
"@types/cookie": "0.6.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/morgan": "1.9.10",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vitest/ui": "4.0.16",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,19 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cookie from 'cookie';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import { authMiddleware, getAuthStatus } from './lib/auth.js';
|
||||
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
|
||||
import { requireJsonContentType } from './middleware/require-json-content-type.js';
|
||||
import { createAuthRoutes } from './routes/auth/index.js';
|
||||
import { createFsRoutes } from './routes/fs/index.js';
|
||||
import { createHealthRoutes } from './routes/health/index.js';
|
||||
import { createHealthRoutes, createDetailedHandler } 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';
|
||||
@@ -50,6 +54,8 @@ import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
||||
import { createMCPRoutes } from './routes/mcp/index.js';
|
||||
import { MCPTestService } from './services/mcp-test-service.js';
|
||||
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
|
||||
@@ -89,7 +95,7 @@ const app = express();
|
||||
// Middleware
|
||||
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
|
||||
if (ENABLE_REQUEST_LOGGING) {
|
||||
morgan.token('status-colored', (req, res) => {
|
||||
morgan.token('status-colored', (_req, res) => {
|
||||
const status = res.statusCode;
|
||||
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
|
||||
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
|
||||
@@ -103,13 +109,47 @@ if (ENABLE_REQUEST_LOGGING) {
|
||||
})
|
||||
);
|
||||
}
|
||||
// CORS configuration
|
||||
// When using credentials (cookies), origin cannot be '*'
|
||||
// We dynamically allow the requesting origin for local development
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (like mobile apps, curl, Electron)
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If CORS_ORIGIN is set, use it (can be comma-separated list)
|
||||
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
|
||||
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, origin);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For local development, allow localhost origins
|
||||
if (
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:') ||
|
||||
origin.startsWith('http://[::1]:')
|
||||
) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject other origins by default for security
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Create shared event emitter for streaming
|
||||
const events: EventEmitter = createEventEmitter();
|
||||
@@ -121,6 +161,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -137,18 +178,26 @@ setInterval(() => {
|
||||
}
|
||||
}, VALIDATION_CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Mount API routes - health is unauthenticated for monitoring
|
||||
// Require Content-Type: application/json for all API POST/PUT/PATCH requests
|
||||
// This helps prevent CSRF and content-type confusion attacks
|
||||
app.use('/api', requireJsonContentType);
|
||||
|
||||
// Mount API routes - health and auth are unauthenticated
|
||||
app.use('/api/health', createHealthRoutes());
|
||||
app.use('/api/auth', createAuthRoutes());
|
||||
|
||||
// Apply authentication to all other routes
|
||||
app.use('/api', authMiddleware);
|
||||
|
||||
// Protected health endpoint with detailed info
|
||||
app.get('/api/health/detailed', createDetailedHandler());
|
||||
|
||||
app.use('/api/fs', createFsRoutes(events));
|
||||
app.use('/api/agent', createAgentRoutes(agentService, events));
|
||||
app.use('/api/sessions', createSessionsRoutes(agentService));
|
||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes());
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes());
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/setup', createSetupRoutes());
|
||||
@@ -164,6 +213,7 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
|
||||
// Create HTTP server
|
||||
@@ -174,10 +224,55 @@ const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade requests
|
||||
* Checks for API key in header/query, session token in header/query, OR valid session cookie
|
||||
*/
|
||||
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// Convert URL search params to query object
|
||||
const query: Record<string, string | undefined> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
|
||||
// Parse cookies from header
|
||||
const cookieHeader = request.headers.cookie;
|
||||
const cookies = cookieHeader ? cookie.parse(cookieHeader) : {};
|
||||
|
||||
// Use shared authentication logic for standard auth methods
|
||||
if (
|
||||
checkRawAuthentication(
|
||||
request.headers as Record<string, string | string[] | undefined>,
|
||||
query,
|
||||
cookies
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Additionally check for short-lived WebSocket connection token (WebSocket-specific)
|
||||
const wsToken = url.searchParams.get('wsToken');
|
||||
if (wsToken && validateWsConnectionToken(wsToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle HTTP upgrade requests manually to route to correct WebSocket server
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// Authenticate all WebSocket connections
|
||||
if (!authenticateWebSocket(request)) {
|
||||
console.log('[WebSocket] Authentication failed, rejecting connection');
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/events') {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
|
||||
@@ -1,54 +1,378 @@
|
||||
/**
|
||||
* Authentication middleware for API security
|
||||
*
|
||||
* Supports API key authentication via header or environment variable.
|
||||
* Supports two authentication methods:
|
||||
* 1. Header-based (X-API-Key) - Used by Electron mode
|
||||
* 2. Cookie-based (HTTP-only session cookie) - Used by web mode
|
||||
*
|
||||
* Auto-generates an API key on first run if none is configured.
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import * as secureFs from './secure-fs.js';
|
||||
|
||||
// API key from environment (optional - if not set, auth is disabled)
|
||||
const API_KEY = process.env.AUTOMAKER_API_KEY;
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
|
||||
const SESSION_COOKIE_NAME = 'automaker_session';
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
|
||||
|
||||
// Session store - persisted to file for survival across server restarts
|
||||
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||
|
||||
// Short-lived WebSocket connection tokens (in-memory only, not persisted)
|
||||
const wsConnectionTokens = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||
|
||||
// Clean up expired WebSocket tokens periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
wsConnectionTokens.forEach((data, token) => {
|
||||
if (data.expiresAt <= now) {
|
||||
wsConnectionTokens.delete(token);
|
||||
}
|
||||
});
|
||||
}, 60 * 1000); // Clean up every minute
|
||||
|
||||
/**
|
||||
* Load sessions from file on startup
|
||||
*/
|
||||
function loadSessions(): void {
|
||||
try {
|
||||
if (secureFs.existsSync(SESSIONS_FILE)) {
|
||||
const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string;
|
||||
const sessions = JSON.parse(data) as Array<
|
||||
[string, { createdAt: number; expiresAt: number }]
|
||||
>;
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [token, session] of sessions) {
|
||||
// Only load non-expired sessions
|
||||
if (session.expiresAt > now) {
|
||||
validSessions.set(token, session);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedCount > 0 || expiredCount > 0) {
|
||||
console.log(`[Auth] Loaded ${loadedCount} sessions (${expiredCount} expired)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error loading sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sessions to file (async)
|
||||
*/
|
||||
async function saveSessions(): Promise<void> {
|
||||
try {
|
||||
await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
const sessions = Array.from(validSessions.entries());
|
||||
await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing sessions on startup
|
||||
loadSessions();
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - either from env var, file, or generate new one.
|
||||
* This provides CSRF protection by requiring a secret key for all API requests.
|
||||
*/
|
||||
function ensureApiKey(): string {
|
||||
// First check environment variable (Electron passes it this way)
|
||||
if (process.env.AUTOMAKER_API_KEY) {
|
||||
console.log('[Auth] Using API key from environment variable');
|
||||
return process.env.AUTOMAKER_API_KEY;
|
||||
}
|
||||
|
||||
// Try to read from file
|
||||
try {
|
||||
if (secureFs.existsSync(API_KEY_FILE)) {
|
||||
const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim();
|
||||
if (key) {
|
||||
console.log('[Auth] Loaded API key from file');
|
||||
return key;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error reading API key file:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
const newKey = crypto.randomUUID();
|
||||
try {
|
||||
secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
|
||||
secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Auth] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save API key:', error);
|
||||
}
|
||||
return newKey;
|
||||
}
|
||||
|
||||
// API key - always generated/loaded on startup for CSRF protection
|
||||
const API_KEY = ensureApiKey();
|
||||
|
||||
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ 🔐 API Key for Web Mode Authentication ║
|
||||
╠═══════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ When accessing via browser, you'll be prompted to enter this key: ║
|
||||
║ ║
|
||||
║ ${API_KEY}
|
||||
║ ║
|
||||
║ In Electron mode, authentication is handled automatically. ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
console.log('[Auth] API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure session token
|
||||
*/
|
||||
function generateSessionToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session and return the token
|
||||
*/
|
||||
export async function createSession(): Promise<string> {
|
||||
const token = generateSessionToken();
|
||||
const now = Date.now();
|
||||
validSessions.set(token, {
|
||||
createdAt: now,
|
||||
expiresAt: now + SESSION_MAX_AGE_MS,
|
||||
});
|
||||
await saveSessions(); // Persist to file
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session token
|
||||
* Note: This returns synchronously but triggers async persistence if session expired
|
||||
*/
|
||||
export function validateSession(token: string): boolean {
|
||||
const session = validSessions.get(token);
|
||||
if (!session) return false;
|
||||
|
||||
if (Date.now() > session.expiresAt) {
|
||||
validSessions.delete(token);
|
||||
// Fire-and-forget: persist removal asynchronously
|
||||
saveSessions().catch((err) => console.error('[Auth] Error saving sessions:', err));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a session token
|
||||
*/
|
||||
export async function invalidateSession(token: string): Promise<void> {
|
||||
validSessions.delete(token);
|
||||
await saveSessions(); // Persist removal
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short-lived WebSocket connection token
|
||||
* Used for initial WebSocket handshake authentication
|
||||
*/
|
||||
export function createWsConnectionToken(): string {
|
||||
const token = generateSessionToken();
|
||||
const now = Date.now();
|
||||
wsConnectionTokens.set(token, {
|
||||
createdAt: now,
|
||||
expiresAt: now + WS_TOKEN_MAX_AGE_MS,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a WebSocket connection token
|
||||
* These tokens are single-use and short-lived (5 minutes)
|
||||
* Token is invalidated immediately after first successful use
|
||||
*/
|
||||
export function validateWsConnectionToken(token: string): boolean {
|
||||
const tokenData = wsConnectionTokens.get(token);
|
||||
if (!tokenData) return false;
|
||||
|
||||
// Always delete the token (single-use)
|
||||
wsConnectionTokens.delete(token);
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() > tokenData.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the API key using timing-safe comparison
|
||||
* Prevents timing attacks that could leak information about the key
|
||||
*/
|
||||
export function validateApiKey(key: string): boolean {
|
||||
if (!key || typeof key !== 'string') return false;
|
||||
|
||||
// Both buffers must be the same length for timingSafeEqual
|
||||
const keyBuffer = Buffer.from(key);
|
||||
const apiKeyBuffer = Buffer.from(API_KEY);
|
||||
|
||||
// If lengths differ, compare against a dummy to maintain constant time
|
||||
if (keyBuffer.length !== apiKeyBuffer.length) {
|
||||
crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session cookie options
|
||||
*/
|
||||
export function getSessionCookieOptions(): {
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: 'strict' | 'lax' | 'none';
|
||||
maxAge: number;
|
||||
path: string;
|
||||
} {
|
||||
return {
|
||||
httpOnly: true, // JavaScript cannot access this cookie
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
|
||||
maxAge: SESSION_MAX_AGE_MS,
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session cookie name
|
||||
*/
|
||||
export function getSessionCookieName(): string {
|
||||
return SESSION_COOKIE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication result type
|
||||
*/
|
||||
type AuthResult =
|
||||
| { authenticated: true }
|
||||
| { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' };
|
||||
|
||||
/**
|
||||
* Core authentication check - shared between middleware and status check
|
||||
* Extracts auth credentials from various sources and validates them
|
||||
*/
|
||||
function checkAuthentication(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
query: Record<string, string | undefined>,
|
||||
cookies: Record<string, string | undefined>
|
||||
): AuthResult {
|
||||
// Check for API key in header (Electron mode)
|
||||
const headerKey = headers['x-api-key'] as string | undefined;
|
||||
if (headerKey) {
|
||||
if (validateApiKey(headerKey)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||
}
|
||||
|
||||
// Check for session token in header (web mode with explicit token)
|
||||
const sessionTokenHeader = headers['x-session-token'] as string | undefined;
|
||||
if (sessionTokenHeader) {
|
||||
if (validateSession(sessionTokenHeader)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
return { authenticated: false, errorType: 'invalid_session' };
|
||||
}
|
||||
|
||||
// Check for API key in query parameter (fallback)
|
||||
const queryKey = query.apiKey;
|
||||
if (queryKey) {
|
||||
if (validateApiKey(queryKey)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||
}
|
||||
|
||||
// Check for session cookie (web mode)
|
||||
const sessionToken = cookies[SESSION_COOKIE_NAME];
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
|
||||
return { authenticated: false, errorType: 'no_auth' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
*
|
||||
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
|
||||
* If not set, allows all requests (development mode).
|
||||
* Accepts either:
|
||||
* 1. X-API-Key header (for Electron mode)
|
||||
* 2. X-Session-Token header (for web mode with explicit token)
|
||||
* 3. apiKey query parameter (fallback for cases where headers can't be set)
|
||||
* 4. Session cookie (for web mode)
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
// If no API key is configured, allow all requests
|
||||
if (!API_KEY) {
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
(req.cookies || {}) as Record<string, string | undefined>
|
||||
);
|
||||
|
||||
if (result.authenticated) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for API key in header
|
||||
const providedKey = req.headers['x-api-key'] as string | undefined;
|
||||
|
||||
if (!providedKey) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required. Provide X-API-Key header.',
|
||||
});
|
||||
return;
|
||||
// Return appropriate error based on what failed
|
||||
switch (result.errorType) {
|
||||
case 'invalid_api_key':
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
break;
|
||||
case 'invalid_session':
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired session token.',
|
||||
});
|
||||
break;
|
||||
case 'no_auth':
|
||||
default:
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
}
|
||||
|
||||
if (providedKey !== API_KEY) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication is enabled
|
||||
* Check if authentication is enabled (always true now)
|
||||
*/
|
||||
export function isAuthEnabled(): boolean {
|
||||
return !!API_KEY;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +380,31 @@ export function isAuthEnabled(): boolean {
|
||||
*/
|
||||
export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
return {
|
||||
enabled: !!API_KEY,
|
||||
method: API_KEY ? 'api_key' : 'none',
|
||||
enabled: true,
|
||||
method: 'api_key_or_session',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is authenticated (for status endpoint)
|
||||
*/
|
||||
export function isRequestAuthenticated(req: Request): boolean {
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
(req.cookies || {}) as Record<string, string | undefined>
|
||||
);
|
||||
return result.authenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if raw credentials are authenticated
|
||||
* Used for WebSocket authentication where we don't have Express request objects
|
||||
*/
|
||||
export function checkRawAuthentication(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
query: Record<string, string | undefined>,
|
||||
cookies: Record<string, string | undefined>
|
||||
): boolean {
|
||||
return checkAuthentication(headers, query, cookies).authenticated;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import path from 'path';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
@@ -231,6 +231,53 @@ function getBaseOptions(): Partial<Options> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP permission options result
|
||||
*/
|
||||
interface McpPermissionOptions {
|
||||
/** Whether tools should be restricted to a preset */
|
||||
shouldRestrictTools: boolean;
|
||||
/** Options to spread when MCP bypass is enabled */
|
||||
bypassOptions: Partial<Options>;
|
||||
/** Options to spread for MCP servers */
|
||||
mcpServerOptions: Partial<Options>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build MCP-related options based on configuration.
|
||||
* Centralizes the logic for determining permission modes and tool restrictions
|
||||
* when MCP servers are configured.
|
||||
*
|
||||
* @param config - The SDK options config
|
||||
* @returns Object with MCP permission settings to spread into final options
|
||||
*/
|
||||
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
||||
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
|
||||
|
||||
// Determine if we should bypass permissions based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
|
||||
return {
|
||||
shouldRestrictTools,
|
||||
// Only include bypass options when MCP is configured and auto-approve is enabled
|
||||
bypassOptions: shouldBypassPermissions
|
||||
? {
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
// Required flag when using bypassPermissions mode
|
||||
allowDangerouslySkipPermissions: true,
|
||||
}
|
||||
: {},
|
||||
// Include MCP servers if configured
|
||||
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
||||
* When autoLoadClaudeMd is true:
|
||||
@@ -314,8 +361,25 @@ export interface CreateSdkOptionsConfig {
|
||||
|
||||
/** Enable sandbox mode for bash command isolation */
|
||||
enableSandboxMode?: boolean;
|
||||
|
||||
/** MCP servers to make available to the agent */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
|
||||
/** Auto-approve MCP tool calls without permission prompts */
|
||||
mcpAutoApproveTools?: boolean;
|
||||
|
||||
/** Allow unrestricted tools when MCP servers are enabled */
|
||||
mcpUnrestrictedTools?: boolean;
|
||||
}
|
||||
|
||||
// Re-export MCP types from @automaker/types for convenience
|
||||
export type {
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Create SDK options for spec generation
|
||||
*
|
||||
@@ -425,6 +489,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||
|
||||
@@ -433,7 +500,10 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
maxTurns: MAX_TURNS.standard,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.chat],
|
||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(sandboxCheck.enabled && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
@@ -442,6 +512,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -462,6 +533,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||
|
||||
@@ -470,7 +544,10 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(sandboxCheck.enabled && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
@@ -479,6 +556,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -501,14 +579,27 @@ export function createCustomOptions(
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
|
||||
const effectiveAllowedTools = config.allowedTools
|
||||
? [...config.allowedTools]
|
||||
: mcpOptions.shouldRestrictTools
|
||||
? [...TOOL_PRESETS.readOnly]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('default', config.model),
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
|
||||
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
|
||||
...(config.sandbox && { sandbox: config.sandbox }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
export const {
|
||||
// Async methods
|
||||
access,
|
||||
readFile,
|
||||
writeFile,
|
||||
@@ -20,6 +21,16 @@ export const {
|
||||
lstat,
|
||||
joinPath,
|
||||
resolvePath,
|
||||
// Sync methods
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
accessSync,
|
||||
unlinkSync,
|
||||
rmSync,
|
||||
// Throttling configuration and monitoring
|
||||
configureThrottling,
|
||||
getThrottlingConfig,
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
|
||||
import type { SettingsService } from '../services/settings-service.js';
|
||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
|
||||
import {
|
||||
mergeAutoModePrompts,
|
||||
mergeAgentPrompts,
|
||||
mergeBacklogPlanPrompts,
|
||||
mergeEnhancementPrompts,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
const logger = createLogger('SettingsHelper');
|
||||
|
||||
/**
|
||||
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
||||
@@ -20,7 +30,7 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<boolean> {
|
||||
if (!settingsService) {
|
||||
console.log(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
|
||||
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -28,7 +38,7 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
// Check project settings first (takes precedence)
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
if (projectSettings.autoLoadClaudeMd !== undefined) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`${logPrefix} autoLoadClaudeMd from project settings: ${projectSettings.autoLoadClaudeMd}`
|
||||
);
|
||||
return projectSettings.autoLoadClaudeMd;
|
||||
@@ -37,10 +47,10 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
// Fall back to global settings
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.autoLoadClaudeMd ?? false;
|
||||
console.log(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
||||
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
|
||||
logger.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -58,17 +68,17 @@ export async function getEnableSandboxModeSetting(
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<boolean> {
|
||||
if (!settingsService) {
|
||||
console.log(`${logPrefix} SettingsService not available, sandbox mode disabled`);
|
||||
logger.info(`${logPrefix} SettingsService not available, sandbox mode disabled`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.enableSandboxMode ?? true;
|
||||
console.log(`${logPrefix} enableSandboxMode from global settings: ${result}`);
|
||||
const result = globalSettings.enableSandboxMode ?? false;
|
||||
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
|
||||
logger.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -136,3 +146,161 @@ function formatContextFileEntry(file: ContextFileInfo): string {
|
||||
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
|
||||
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled MCP servers from global settings, converted to SDK format.
|
||||
* Returns an empty object if settings service is not available or no servers are configured.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to MCP servers in SDK format (keyed by name)
|
||||
*/
|
||||
export async function getMCPServersFromSettings(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<Record<string, McpServerConfig>> {
|
||||
if (!settingsService) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const mcpServers = globalSettings.mcpServers || [];
|
||||
|
||||
// Filter to only enabled servers and convert to SDK format
|
||||
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
||||
|
||||
if (enabledServers.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Convert settings format to SDK format (keyed by name)
|
||||
const sdkServers: Record<string, McpServerConfig> = {};
|
||||
for (const server of enabledServers) {
|
||||
sdkServers[server.name] = convertToSdkFormat(server);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}`
|
||||
);
|
||||
|
||||
return sdkServers;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load MCP servers setting:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP permission settings from global settings.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to MCP permission settings
|
||||
*/
|
||||
export async function getMCPPermissionSettings(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> {
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true };
|
||||
|
||||
if (!settingsService) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = {
|
||||
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
|
||||
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
|
||||
};
|
||||
logger.info(
|
||||
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load MCP permission settings:`, error);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a settings MCPServerConfig to SDK McpServerConfig format.
|
||||
* Validates required fields and throws informative errors if missing.
|
||||
*/
|
||||
function convertToSdkFormat(server: MCPServerConfig): McpServerConfig {
|
||||
if (server.type === 'sse') {
|
||||
if (!server.url) {
|
||||
throw new Error(`SSE MCP server "${server.name}" is missing a URL.`);
|
||||
}
|
||||
return {
|
||||
type: 'sse',
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
};
|
||||
}
|
||||
|
||||
if (server.type === 'http') {
|
||||
if (!server.url) {
|
||||
throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`);
|
||||
}
|
||||
return {
|
||||
type: 'http',
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Default to stdio
|
||||
if (!server.command) {
|
||||
throw new Error(`Stdio MCP server "${server.name}" is missing a command.`);
|
||||
}
|
||||
return {
|
||||
type: 'stdio',
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prompt customization from global settings and merge with defaults.
|
||||
* Returns prompts merged with built-in defaults - custom prompts override defaults.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to merged prompts for all categories
|
||||
*/
|
||||
export async function getPromptCustomization(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[PromptHelper]'
|
||||
): Promise<{
|
||||
autoMode: ReturnType<typeof mergeAutoModePrompts>;
|
||||
agent: ReturnType<typeof mergeAgentPrompts>;
|
||||
backlogPlan: ReturnType<typeof mergeBacklogPlanPrompts>;
|
||||
enhancement: ReturnType<typeof mergeEnhancementPrompts>;
|
||||
}> {
|
||||
let customization: PromptCustomization = {};
|
||||
|
||||
if (settingsService) {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
customization = globalSettings.promptCustomization || {};
|
||||
logger.info(`${logPrefix} Loaded prompt customization from settings`);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load prompt customization:`, error);
|
||||
// Fall through to use empty customization (all defaults)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${logPrefix} SettingsService not available, using default prompts`);
|
||||
}
|
||||
|
||||
return {
|
||||
autoMode: mergeAutoModePrompts(customization.autoMode),
|
||||
agent: mergeAgentPrompts(customization.agent),
|
||||
backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan),
|
||||
enhancement: mergeEnhancementPrompts(customization.enhancement),
|
||||
};
|
||||
}
|
||||
|
||||
33
apps/server/src/lib/version.ts
Normal file
33
apps/server/src/lib/version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Version utility - Reads version from package.json
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
let cachedVersion: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the version from package.json
|
||||
* Caches the result for performance
|
||||
*/
|
||||
export function getVersion(): string {
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
const version = packageJson.version || '0.0.0';
|
||||
cachedVersion = version;
|
||||
return version;
|
||||
} catch (error) {
|
||||
console.warn('Failed to read version from package.json:', error);
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
50
apps/server/src/middleware/require-json-content-type.ts
Normal file
50
apps/server/src/middleware/require-json-content-type.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Middleware to enforce Content-Type: application/json for request bodies
|
||||
*
|
||||
* This security middleware prevents malicious requests by requiring proper
|
||||
* Content-Type headers for all POST, PUT, and PATCH requests.
|
||||
*
|
||||
* Rejecting requests without proper Content-Type helps prevent:
|
||||
* - CSRF attacks via form submissions (which use application/x-www-form-urlencoded)
|
||||
* - Content-type confusion attacks
|
||||
* - Malformed request exploitation
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// HTTP methods that typically include request bodies
|
||||
const METHODS_REQUIRING_JSON = ['POST', 'PUT', 'PATCH'];
|
||||
|
||||
/**
|
||||
* Middleware that requires Content-Type: application/json for POST/PUT/PATCH requests
|
||||
*
|
||||
* Returns 415 Unsupported Media Type if:
|
||||
* - The request method is POST, PUT, or PATCH
|
||||
* - AND the Content-Type header is missing or not application/json
|
||||
*
|
||||
* Allows requests to pass through if:
|
||||
* - The request method is GET, DELETE, OPTIONS, HEAD, etc.
|
||||
* - OR the Content-Type is properly set to application/json (with optional charset)
|
||||
*/
|
||||
export function requireJsonContentType(req: Request, res: Response, next: NextFunction): void {
|
||||
// Skip validation for methods that don't require a body
|
||||
if (!METHODS_REQUIRING_JSON.includes(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = req.headers['content-type'];
|
||||
|
||||
// Check if Content-Type header exists and contains application/json
|
||||
// Allows for charset parameter: "application/json; charset=utf-8"
|
||||
if (!contentType || !contentType.toLowerCase().includes('application/json')) {
|
||||
res.status(415).json({
|
||||
success: false,
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'Content-Type header must be application/json',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
@@ -14,6 +15,32 @@ import type {
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
|
||||
// Explicit allowlist of environment variables to pass to the SDK.
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
'TERM',
|
||||
'USER',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build environment for the SDK with only explicitly allowed variables
|
||||
*/
|
||||
function buildEnv(): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export class ClaudeProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return 'claude';
|
||||
@@ -36,16 +63,35 @@ export class ClaudeProvider extends BaseProvider {
|
||||
} = options;
|
||||
|
||||
// Build Claude SDK options
|
||||
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
|
||||
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
|
||||
// the provider is the final point where SDK options are constructed.
|
||||
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
|
||||
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
const toolsToUse = allowedTools || defaultTools;
|
||||
|
||||
// Determine permission mode based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
model,
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
allowedTools: toolsToUse,
|
||||
permissionMode: 'default',
|
||||
// Pass only explicitly allowed environment variables to SDK
|
||||
env: buildEnv(),
|
||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
|
||||
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
|
||||
// Required when using bypassPermissions mode
|
||||
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
|
||||
abortController,
|
||||
// Resume existing SDK session if we have a session ID
|
||||
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
||||
@@ -55,6 +101,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
...(options.settingSources && { settingSources: options.settingSources }),
|
||||
// Forward sandbox configuration
|
||||
...(options.sandbox && { sandbox: options.sandbox }),
|
||||
// Forward MCP servers configuration
|
||||
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
@@ -88,9 +136,32 @@ export class ClaudeProvider extends BaseProvider {
|
||||
yield msg as ProviderMessage;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error);
|
||||
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
|
||||
throw error;
|
||||
// Enhance error with user-friendly message and classification
|
||||
const errorInfo = classifyError(error);
|
||||
const userMessage = getUserFriendlyErrorMessage(error);
|
||||
|
||||
console.error('[ClaudeProvider] executeQuery() error during execution:', {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
isRateLimit: errorInfo.isRateLimit,
|
||||
retryAfter: errorInfo.retryAfter,
|
||||
stack: (error as Error).stack,
|
||||
});
|
||||
|
||||
// Build enhanced error message with additional guidance for rate limits
|
||||
const message = errorInfo.isRateLimit
|
||||
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
|
||||
: userMessage;
|
||||
|
||||
const enhancedError = new Error(message);
|
||||
(enhancedError as any).originalError = error;
|
||||
(enhancedError as any).type = errorInfo.type;
|
||||
|
||||
if (errorInfo.isRateLimit) {
|
||||
(enhancedError as any).retryAfter = errorInfo.retryAfter;
|
||||
}
|
||||
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
/**
|
||||
* Shared types for AI model providers
|
||||
*
|
||||
* Re-exports types from @automaker/types for consistency across the codebase.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for a provider instance
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
apiKey?: string;
|
||||
cliPath?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message in conversation history
|
||||
*/
|
||||
export interface ConversationMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a query via a provider
|
||||
*/
|
||||
export interface ExecuteOptions {
|
||||
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
model: string;
|
||||
cwd: string;
|
||||
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
mcpServers?: Record<string, unknown>;
|
||||
abortController?: AbortController;
|
||||
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
|
||||
settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load
|
||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
|
||||
}
|
||||
// Re-export all provider types from @automaker/types
|
||||
export type {
|
||||
ProviderConfig,
|
||||
ConversationMessage,
|
||||
ExecuteOptions,
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Content block in a provider message (matches Claude SDK format)
|
||||
|
||||
247
apps/server/src/routes/auth/index.ts
Normal file
247
apps/server/src/routes/auth/index.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Auth routes - Login, logout, and status endpoints
|
||||
*
|
||||
* Security model:
|
||||
* - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie
|
||||
* - Electron mode: Uses X-API-Key header (handled automatically via IPC)
|
||||
*
|
||||
* The session cookie is:
|
||||
* - HTTP-only: JavaScript cannot read it (protects against XSS)
|
||||
* - SameSite=Strict: Only sent for same-site requests (protects against CSRF)
|
||||
*
|
||||
* Mounted at /api/auth in the main server (BEFORE auth middleware).
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
validateApiKey,
|
||||
createSession,
|
||||
invalidateSession,
|
||||
getSessionCookieOptions,
|
||||
getSessionCookieName,
|
||||
isRequestAuthenticated,
|
||||
createWsConnectionToken,
|
||||
} from '../../lib/auth.js';
|
||||
|
||||
// Rate limiting configuration
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
|
||||
const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window
|
||||
|
||||
// Check if we're in test mode - disable rate limiting for E2E tests
|
||||
const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true';
|
||||
|
||||
// In-memory rate limit tracking (resets on server restart)
|
||||
const loginAttempts = new Map<string, { count: number; windowStart: number }>();
|
||||
|
||||
// Clean up old rate limit entries periodically (every 5 minutes)
|
||||
setInterval(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
loginAttempts.forEach((data, ip) => {
|
||||
if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
|
||||
loginAttempts.delete(ip);
|
||||
}
|
||||
});
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
/**
|
||||
* Get client IP address from request
|
||||
* Handles X-Forwarded-For header for reverse proxy setups
|
||||
*/
|
||||
function getClientIp(req: Request): string {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) {
|
||||
// X-Forwarded-For can be a comma-separated list; take the first (original client)
|
||||
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
|
||||
return forwardedIp.trim();
|
||||
}
|
||||
return req.ip || req.socket.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is rate limited
|
||||
* Returns { limited: boolean, retryAfter?: number }
|
||||
*/
|
||||
function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } {
|
||||
const now = Date.now();
|
||||
const attempt = loginAttempts.get(ip);
|
||||
|
||||
if (!attempt) {
|
||||
return { limited: false };
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||
loginAttempts.delete(ip);
|
||||
return { limited: false };
|
||||
}
|
||||
|
||||
// Check if over limit
|
||||
if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) {
|
||||
const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000);
|
||||
return { limited: true, retryAfter };
|
||||
}
|
||||
|
||||
return { limited: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a login attempt for rate limiting
|
||||
*/
|
||||
function recordLoginAttempt(ip: string): void {
|
||||
const now = Date.now();
|
||||
const attempt = loginAttempts.get(ip);
|
||||
|
||||
if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||
// Start new window
|
||||
loginAttempts.set(ip, { count: 1, windowStart: now });
|
||||
} else {
|
||||
// Increment existing window
|
||||
attempt.count++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth routes
|
||||
*
|
||||
* @returns Express Router with auth endpoints
|
||||
*/
|
||||
export function createAuthRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
*
|
||||
* Returns whether the current request is authenticated.
|
||||
* Used by the UI to determine if login is needed.
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
const authenticated = isRequestAuthenticated(req);
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
*
|
||||
* Validates the API key and sets a session cookie.
|
||||
* Body: { apiKey: string }
|
||||
*
|
||||
* Rate limited to 5 attempts per minute per IP to prevent brute force attacks.
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
const clientIp = getClientIp(req);
|
||||
|
||||
// Skip rate limiting in test mode to allow parallel E2E tests
|
||||
if (!isTestMode) {
|
||||
// Check rate limit before processing
|
||||
const rateLimit = checkRateLimit(clientIp);
|
||||
if (rateLimit.limited) {
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: 'Too many login attempts. Please try again later.',
|
||||
retryAfter: rateLimit.retryAfter,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { apiKey } = req.body as { apiKey?: string };
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'API key is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Record this attempt (only for actual API key validation attempts, skip in test mode)
|
||||
if (!isTestMode) {
|
||||
recordLoginAttempt(clientIp);
|
||||
}
|
||||
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create session and set cookie
|
||||
const sessionToken = await createSession();
|
||||
const cookieOptions = getSessionCookieOptions();
|
||||
const cookieName = getSessionCookieName();
|
||||
|
||||
res.cookie(cookieName, sessionToken, cookieOptions);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged in successfully.',
|
||||
// Return token for explicit header-based auth (works around cross-origin cookie issues)
|
||||
token: sessionToken,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/token
|
||||
*
|
||||
* Generates a short-lived WebSocket connection token if the user has a valid session.
|
||||
* This token is used for initial WebSocket handshake authentication and expires in 5 minutes.
|
||||
* The token is NOT the session cookie value - it's a separate, short-lived token.
|
||||
*/
|
||||
router.get('/token', (req, res) => {
|
||||
// Validate the session is still valid (via cookie, API key, or session token header)
|
||||
if (!isRequestAuthenticated(req)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a new short-lived WebSocket connection token
|
||||
const wsToken = createWsConnectionToken();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token: wsToken,
|
||||
expiresIn: 300, // 5 minutes in seconds
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
*
|
||||
* Clears the session cookie and invalidates the session.
|
||||
*/
|
||||
router.post('/logout', async (req, res) => {
|
||||
const cookieName = getSessionCookieName();
|
||||
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
||||
|
||||
if (sessionToken) {
|
||||
await invalidateSession(sessionToken);
|
||||
}
|
||||
|
||||
// Clear the cookie
|
||||
res.clearCookie(cookieName, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged out successfully.',
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { logger, setRunningState, getErrorMessage } from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
@@ -79,72 +79,17 @@ export async function generateBacklogPlan(
|
||||
content: `Loaded ${features.length} features from backlog`,
|
||||
});
|
||||
|
||||
// Load prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[BacklogPlan]');
|
||||
|
||||
// Build the system prompt
|
||||
const systemPrompt = `You are an AI assistant helping to modify a software project's feature backlog.
|
||||
You will be given the current list of features and a user request to modify the backlog.
|
||||
const systemPrompt = prompts.backlogPlan.systemPrompt;
|
||||
|
||||
IMPORTANT CONTEXT (automatically injected):
|
||||
- Remember to update the dependency graph if deleting existing features
|
||||
- Remember to define dependencies on new features hooked into relevant existing ones
|
||||
- Maintain dependency graph integrity (no orphaned dependencies)
|
||||
- When deleting a feature, identify which other features depend on it
|
||||
|
||||
Your task is to analyze the request and produce a structured JSON plan with:
|
||||
1. Features to ADD (include title, description, category, and dependencies)
|
||||
2. Features to UPDATE (specify featureId and the updates)
|
||||
3. Features to DELETE (specify featureId)
|
||||
4. A summary of the changes
|
||||
5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features)
|
||||
|
||||
Respond with ONLY a JSON object in this exact format:
|
||||
\`\`\`json
|
||||
{
|
||||
"changes": [
|
||||
{
|
||||
"type": "add",
|
||||
"feature": {
|
||||
"title": "Feature title",
|
||||
"description": "Feature description",
|
||||
"category": "Category name",
|
||||
"dependencies": ["existing-feature-id"],
|
||||
"priority": 1
|
||||
},
|
||||
"reason": "Why this feature should be added"
|
||||
},
|
||||
{
|
||||
"type": "update",
|
||||
"featureId": "existing-feature-id",
|
||||
"feature": {
|
||||
"title": "Updated title"
|
||||
},
|
||||
"reason": "Why this feature should be updated"
|
||||
},
|
||||
{
|
||||
"type": "delete",
|
||||
"featureId": "feature-id-to-delete",
|
||||
"reason": "Why this feature should be deleted"
|
||||
}
|
||||
],
|
||||
"summary": "Brief overview of all proposed changes",
|
||||
"dependencyUpdates": [
|
||||
{
|
||||
"featureId": "feature-that-depended-on-deleted",
|
||||
"removedDependencies": ["deleted-feature-id"],
|
||||
"addedDependencies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
// Build the user prompt
|
||||
const userPrompt = `Current Features in Backlog:
|
||||
${formatFeaturesForPrompt(features)}
|
||||
|
||||
---
|
||||
|
||||
User Request: ${prompt}
|
||||
|
||||
Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`;
|
||||
// Build the user prompt from template
|
||||
const currentFeatures = formatFeaturesForPrompt(features);
|
||||
const userPrompt = prompts.backlogPlan.userPromptTemplate
|
||||
.replace('{{currentFeatures}}', currentFeatures)
|
||||
.replace('{{userRequest}}', prompt);
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
|
||||
@@ -15,7 +15,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import * as fs from 'fs';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
@@ -57,13 +57,13 @@ function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unk
|
||||
*/
|
||||
function findActualFilePath(requestedPath: string): string | null {
|
||||
// First, try the exact path
|
||||
if (fs.existsSync(requestedPath)) {
|
||||
if (secureFs.existsSync(requestedPath)) {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
// Try with Unicode normalization
|
||||
const normalizedPath = requestedPath.normalize('NFC');
|
||||
if (fs.existsSync(normalizedPath)) {
|
||||
if (secureFs.existsSync(normalizedPath)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ function findActualFilePath(requestedPath: string): string | null {
|
||||
const dir = path.dirname(requestedPath);
|
||||
const baseName = path.basename(requestedPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
if (!secureFs.existsSync(dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
const files = secureFs.readdirSync(dir);
|
||||
|
||||
// Normalize the requested basename for comparison
|
||||
// Replace various space-like characters with regular space for comparison
|
||||
@@ -281,9 +281,9 @@ export function createDescribeImageHandler(
|
||||
}
|
||||
|
||||
// Log path + stats (this is often where issues start: missing file, perms, size)
|
||||
let stat: fs.Stats | null = null;
|
||||
let stat: ReturnType<typeof secureFs.statSync> | null = null;
|
||||
try {
|
||||
stat = fs.statSync(actualPath);
|
||||
stat = secureFs.statSync(actualPath);
|
||||
logger.info(
|
||||
`[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
|
||||
);
|
||||
|
||||
@@ -6,17 +6,19 @@
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { createEnhanceHandler } from './routes/enhance.js';
|
||||
|
||||
/**
|
||||
* Create the enhance-prompt router
|
||||
*
|
||||
* @param settingsService - Settings service for loading custom prompts
|
||||
* @returns Express router with enhance-prompt endpoints
|
||||
*/
|
||||
export function createEnhancePromptRoutes(): Router {
|
||||
export function createEnhancePromptRoutes(settingsService?: SettingsService): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/', createEnhanceHandler());
|
||||
router.post('/', createEnhanceHandler(settingsService));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getSystemPrompt,
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
@@ -83,9 +84,12 @@ async function extractTextFromStream(
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
* @param settingsService - Optional settings service for loading custom prompts
|
||||
* @returns Express request handler for text enhancement
|
||||
*/
|
||||
export function createEnhanceHandler(): (req: Request, res: Response) => Promise<void> {
|
||||
export function createEnhanceHandler(
|
||||
settingsService?: SettingsService
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody;
|
||||
@@ -128,8 +132,19 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
|
||||
|
||||
logger.info(`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`);
|
||||
|
||||
// Get the system prompt for this mode
|
||||
const systemPrompt = getSystemPrompt(validMode);
|
||||
// Load enhancement prompts from settings (merges custom + defaults)
|
||||
const prompts = await getPromptCustomization(settingsService, '[EnhancePrompt]');
|
||||
|
||||
// Get the system prompt for this mode from merged prompts
|
||||
const systemPromptMap: Record<EnhancementMode, string> = {
|
||||
improve: prompts.enhancement.improveSystemPrompt,
|
||||
technical: prompts.enhancement.technicalSystemPrompt,
|
||||
simplify: prompts.enhancement.simplifySystemPrompt,
|
||||
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
||||
};
|
||||
const systemPrompt = systemPromptMap[validMode];
|
||||
|
||||
logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`);
|
||||
|
||||
// Build the user prompt with few-shot examples
|
||||
// This helps the model understand this is text transformation, not a coding task
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError, isPathAllowed } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createBrowseHandler() {
|
||||
@@ -40,9 +40,16 @@ export function createBrowseHandler() {
|
||||
return drives;
|
||||
};
|
||||
|
||||
// Get parent directory
|
||||
// Get parent directory - only if it's within the allowed root
|
||||
const parentPath = path.dirname(targetPath);
|
||||
const hasParent = parentPath !== targetPath;
|
||||
|
||||
// Determine if parent navigation should be allowed:
|
||||
// 1. Must have a different parent (not at filesystem root)
|
||||
// 2. If ALLOWED_ROOT_DIRECTORY is set, parent must be within it
|
||||
const hasParent = parentPath !== targetPath && isPathAllowed(parentPath);
|
||||
|
||||
// Security: Don't expose parent path outside allowed root
|
||||
const safeParentPath = hasParent ? parentPath : null;
|
||||
|
||||
// Get available drives
|
||||
const drives = await detectDrives();
|
||||
@@ -70,7 +77,7 @@ export function createBrowseHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
currentPath: targetPath,
|
||||
parentPath: hasParent ? parentPath : null,
|
||||
parentPath: safeParentPath,
|
||||
directories,
|
||||
drives,
|
||||
});
|
||||
@@ -84,7 +91,7 @@ export function createBrowseHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
currentPath: targetPath,
|
||||
parentPath: hasParent ? parentPath : null,
|
||||
parentPath: safeParentPath,
|
||||
directories: [],
|
||||
drives,
|
||||
warning:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { isPathAllowed } from '@automaker/platform';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createValidatePathHandler() {
|
||||
@@ -20,6 +20,20 @@ export function createValidatePathHandler() {
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Validate path against ALLOWED_ROOT_DIRECTORY before checking if it exists
|
||||
if (!isPathAllowed(resolvedPath)) {
|
||||
const allowedRoot = getAllowedRootDirectory();
|
||||
const errorMessage = allowedRoot
|
||||
? `Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
|
||||
: `Path not allowed: ${filePath}`;
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
isAllowed: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
try {
|
||||
const stats = await secureFs.stat(resolvedPath);
|
||||
@@ -32,7 +46,7 @@ export function createValidatePathHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
path: resolvedPath,
|
||||
isAllowed: isPathAllowed(resolvedPath),
|
||||
isAllowed: true,
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({ success: false, error: 'Path does not exist' });
|
||||
|
||||
@@ -8,6 +8,7 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
|
||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||
import { createListPRsHandler } from './routes/list-prs.js';
|
||||
import { createListCommentsHandler } from './routes/list-comments.js';
|
||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
||||
import {
|
||||
createValidationStatusHandler,
|
||||
@@ -27,6 +28,7 @@ export function createGitHubRoutes(
|
||||
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
|
||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
||||
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
||||
router.post(
|
||||
'/validate-issue',
|
||||
validatePathParams('projectPath'),
|
||||
|
||||
212
apps/server/src/routes/github/routes/list-comments.ts
Normal file
212
apps/server/src/routes/github/routes/list-comments.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* POST /issue-comments endpoint - Fetch comments for a GitHub issue
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { GitHubComment, IssueCommentsResult } from '@automaker/types';
|
||||
import { execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
|
||||
interface ListCommentsRequest {
|
||||
projectPath: string;
|
||||
issueNumber: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
interface GraphQLComment {
|
||||
id: string;
|
||||
author: {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface GraphQLResponse {
|
||||
data?: {
|
||||
repository?: {
|
||||
issue?: {
|
||||
comments: {
|
||||
totalCount: number;
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
nodes: GraphQLComment[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
}
|
||||
|
||||
/** Timeout for GitHub API requests in milliseconds */
|
||||
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Validate cursor format (GraphQL cursors are typically base64 strings)
|
||||
*/
|
||||
function isValidCursor(cursor: string): boolean {
|
||||
return /^[A-Za-z0-9+/=]+$/.test(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch comments for a specific issue using GitHub GraphQL API
|
||||
*/
|
||||
async function fetchIssueComments(
|
||||
projectPath: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
cursor?: string
|
||||
): Promise<IssueCommentsResult> {
|
||||
// Validate cursor format to prevent potential injection
|
||||
if (cursor && !isValidCursor(cursor)) {
|
||||
throw new Error('Invalid cursor format');
|
||||
}
|
||||
|
||||
// Use GraphQL variables instead of string interpolation for safety
|
||||
const query = `
|
||||
query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issue(number: $issueNumber) {
|
||||
comments(first: 50, after: $cursor) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
author {
|
||||
login
|
||||
avatarUrl
|
||||
}
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables = {
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
cursor: cursor || null,
|
||||
};
|
||||
|
||||
const requestBody = JSON.stringify({ query, variables });
|
||||
|
||||
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Add timeout to prevent hanging indefinitely
|
||||
const timeoutId = setTimeout(() => {
|
||||
gh.kill();
|
||||
reject(new Error('GitHub API request timed out'));
|
||||
}, GITHUB_API_TIMEOUT_MS);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||
|
||||
gh.on('close', (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
gh.stdin.write(requestBody);
|
||||
gh.stdin.end();
|
||||
});
|
||||
|
||||
if (response.errors && response.errors.length > 0) {
|
||||
throw new Error(response.errors[0].message);
|
||||
}
|
||||
|
||||
const commentsData = response.data?.repository?.issue?.comments;
|
||||
|
||||
if (!commentsData) {
|
||||
throw new Error('Issue not found or no comments data available');
|
||||
}
|
||||
|
||||
const comments: GitHubComment[] = commentsData.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
author: {
|
||||
login: node.author?.login || 'ghost',
|
||||
avatarUrl: node.author?.avatarUrl,
|
||||
},
|
||||
body: node.body,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: node.updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
comments,
|
||||
totalCount: commentsData.totalCount,
|
||||
hasNextPage: commentsData.pageInfo.hasNextPage,
|
||||
endCursor: commentsData.pageInfo.endCursor || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createListCommentsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, issueNumber, cursor } = req.body as ListCommentsRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
||||
return;
|
||||
}
|
||||
|
||||
// First check if this is a GitHub repo and get owner/repo
|
||||
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Project does not have a GitHub remote',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchIssueComments(
|
||||
projectPath,
|
||||
remoteStatus.owner,
|
||||
remoteStatus.repo,
|
||||
issueNumber,
|
||||
cursor
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, `Fetch comments for issue failed`);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -8,13 +8,21 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
|
||||
import type {
|
||||
IssueValidationResult,
|
||||
IssueValidationEvent,
|
||||
AgentModel,
|
||||
GitHubComment,
|
||||
LinkedPRInfo,
|
||||
} from '@automaker/types';
|
||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
||||
import {
|
||||
issueValidationSchema,
|
||||
ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||
buildValidationPrompt,
|
||||
ValidationComment,
|
||||
ValidationLinkedPR,
|
||||
} from './validation-schema.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
@@ -40,6 +48,10 @@ interface ValidateIssueRequestBody {
|
||||
issueLabels?: string[];
|
||||
/** Model to use for validation (opus, sonnet, haiku) */
|
||||
model?: AgentModel;
|
||||
/** Comments to include in validation analysis */
|
||||
comments?: GitHubComment[];
|
||||
/** Linked pull requests for this issue */
|
||||
linkedPRs?: LinkedPRInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +69,9 @@ async function runValidation(
|
||||
model: AgentModel,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService
|
||||
settingsService?: SettingsService,
|
||||
comments?: ValidationComment[],
|
||||
linkedPRs?: ValidationLinkedPR[]
|
||||
): Promise<void> {
|
||||
// Emit start event
|
||||
const startEvent: IssueValidationEvent = {
|
||||
@@ -76,8 +90,15 @@ async function runValidation(
|
||||
}, VALIDATION_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
// Build the prompt
|
||||
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
|
||||
// Build the prompt (include comments and linked PRs if provided)
|
||||
const prompt = buildValidationPrompt(
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
issueBody,
|
||||
issueLabels,
|
||||
comments,
|
||||
linkedPRs
|
||||
);
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
@@ -102,16 +123,12 @@ async function runValidation(
|
||||
// Execute the query
|
||||
const stream = query({ prompt, options });
|
||||
let validationResult: IssueValidationResult | null = null;
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Collect assistant text for debugging and emit progress
|
||||
// Emit progress events for assistant text
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
|
||||
// Emit progress event
|
||||
const progressEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_progress',
|
||||
issueNumber,
|
||||
@@ -128,7 +145,6 @@ async function runValidation(
|
||||
const resultMsg = msg as { structured_output?: IssueValidationResult };
|
||||
if (resultMsg.structured_output) {
|
||||
validationResult = resultMsg.structured_output;
|
||||
logger.debug('Received structured output:', validationResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +164,6 @@ async function runValidation(
|
||||
// Require structured output
|
||||
if (!validationResult) {
|
||||
logger.error('No structured output received from Claude SDK');
|
||||
logger.debug('Raw response text:', responseText);
|
||||
throw new Error('Validation failed: no structured output received');
|
||||
}
|
||||
|
||||
@@ -214,8 +229,30 @@ export function createValidateIssueHandler(
|
||||
issueBody,
|
||||
issueLabels,
|
||||
model = 'opus',
|
||||
comments: rawComments,
|
||||
linkedPRs: rawLinkedPRs,
|
||||
} = req.body as ValidateIssueRequestBody;
|
||||
|
||||
// Transform GitHubComment[] to ValidationComment[] if provided
|
||||
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
|
||||
author: c.author?.login || 'ghost',
|
||||
createdAt: c.createdAt,
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
// Transform LinkedPRInfo[] to ValidationLinkedPR[] if provided
|
||||
const validationLinkedPRs: ValidationLinkedPR[] | undefined = rawLinkedPRs?.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.state,
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
`[ValidateIssue] Received validation request for issue #${issueNumber}` +
|
||||
(rawComments?.length ? ` with ${rawComments.length} comments` : ' (no comments)') +
|
||||
(rawLinkedPRs?.length ? ` and ${rawLinkedPRs.length} linked PRs` : '')
|
||||
);
|
||||
|
||||
// Validate required fields
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
@@ -271,11 +308,12 @@ export function createValidateIssueHandler(
|
||||
model,
|
||||
events,
|
||||
abortController,
|
||||
settingsService
|
||||
settingsService,
|
||||
validationComments,
|
||||
validationLinkedPRs
|
||||
)
|
||||
.catch((error) => {
|
||||
.catch(() => {
|
||||
// Error is already handled inside runValidation (event emitted)
|
||||
logger.debug('Validation error caught in background handler:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
clearValidationStatus(projectPath, issueNumber);
|
||||
|
||||
@@ -49,6 +49,34 @@ export const issueValidationSchema = {
|
||||
enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'],
|
||||
description: 'Estimated effort to address the issue',
|
||||
},
|
||||
prAnalysis: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hasOpenPR: {
|
||||
type: 'boolean',
|
||||
description: 'Whether there is an open PR linked to this issue',
|
||||
},
|
||||
prFixesIssue: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the PR appears to fix the issue based on the diff',
|
||||
},
|
||||
prNumber: {
|
||||
type: 'number',
|
||||
description: 'The PR number that was analyzed',
|
||||
},
|
||||
prSummary: {
|
||||
type: 'string',
|
||||
description: 'Brief summary of what the PR changes',
|
||||
},
|
||||
recommendation: {
|
||||
type: 'string',
|
||||
enum: ['wait_for_merge', 'pr_needs_work', 'no_pr'],
|
||||
description:
|
||||
'Recommendation: wait for PR to merge, PR needs more work, or no relevant PR',
|
||||
},
|
||||
},
|
||||
description: 'Analysis of linked pull requests if any exist',
|
||||
},
|
||||
},
|
||||
required: ['verdict', 'confidence', 'reasoning'],
|
||||
additionalProperties: false,
|
||||
@@ -67,7 +95,8 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
|
||||
1. **Read the issue carefully** - Understand what is being reported or requested
|
||||
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
|
||||
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
|
||||
4. **Form your verdict** - Based on your analysis, determine if the issue is valid
|
||||
4. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff <PR_NUMBER>\` to review the changes
|
||||
5. **Form your verdict** - Based on your analysis, determine if the issue is valid
|
||||
|
||||
## Verdicts
|
||||
|
||||
@@ -88,12 +117,32 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
|
||||
- Is the implementation location clear?
|
||||
- Is the request technically feasible given the codebase structure?
|
||||
|
||||
## Analyzing Linked Pull Requests
|
||||
|
||||
When an issue has linked PRs (especially open ones), you MUST analyze them:
|
||||
|
||||
1. **Run \`gh pr diff <PR_NUMBER>\`** to see what changes the PR makes
|
||||
2. **Run \`gh pr view <PR_NUMBER>\`** to see PR description and status
|
||||
3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem?
|
||||
4. **Provide a recommendation**:
|
||||
- \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged.
|
||||
- \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems.
|
||||
- \`no_pr\`: No relevant PR exists for this issue.
|
||||
|
||||
5. **Include prAnalysis in your response** with:
|
||||
- hasOpenPR: true/false
|
||||
- prFixesIssue: true/false (based on diff analysis)
|
||||
- prNumber: the PR number you analyzed
|
||||
- prSummary: brief description of what the PR changes
|
||||
- recommendation: one of the above values
|
||||
|
||||
## Response Guidelines
|
||||
|
||||
- **Always include relatedFiles** when you find relevant code
|
||||
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
|
||||
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
|
||||
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
|
||||
- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work
|
||||
- **Set estimatedComplexity** to help prioritize:
|
||||
- trivial: Simple text changes, one-line fixes
|
||||
- simple: Small changes to one file
|
||||
@@ -103,6 +152,24 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
|
||||
|
||||
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
|
||||
|
||||
/**
|
||||
* Comment data structure for validation prompt
|
||||
*/
|
||||
export interface ValidationComment {
|
||||
author: string;
|
||||
createdAt: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked PR data structure for validation prompt
|
||||
*/
|
||||
export interface ValidationLinkedPR {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user prompt for issue validation.
|
||||
*
|
||||
@@ -113,26 +180,60 @@ Be thorough in your analysis but focus on files that are directly relevant to th
|
||||
* @param issueTitle - The issue title
|
||||
* @param issueBody - The issue body/description
|
||||
* @param issueLabels - Optional array of label names
|
||||
* @param comments - Optional array of comments to include in analysis
|
||||
* @param linkedPRs - Optional array of linked pull requests
|
||||
* @returns Formatted prompt string for the validation request
|
||||
*/
|
||||
export function buildValidationPrompt(
|
||||
issueNumber: number,
|
||||
issueTitle: string,
|
||||
issueBody: string,
|
||||
issueLabels?: string[]
|
||||
issueLabels?: string[],
|
||||
comments?: ValidationComment[],
|
||||
linkedPRs?: ValidationLinkedPR[]
|
||||
): string {
|
||||
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
|
||||
|
||||
let linkedPRsSection = '';
|
||||
if (linkedPRs && linkedPRs.length > 0) {
|
||||
const prsText = linkedPRs
|
||||
.map((pr) => `- PR #${pr.number} (${pr.state}): ${pr.title}`)
|
||||
.join('\n');
|
||||
linkedPRsSection = `\n\n### Linked Pull Requests\n\n${prsText}`;
|
||||
}
|
||||
|
||||
let commentsSection = '';
|
||||
if (comments && comments.length > 0) {
|
||||
// Limit to most recent 10 comments to control prompt size
|
||||
const recentComments = comments.slice(-10);
|
||||
const commentsText = recentComments
|
||||
.map(
|
||||
(c) => `**${c.author}** (${new Date(c.createdAt).toISOString().slice(0, 10)}):\n${c.body}`
|
||||
)
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
commentsSection = `\n\n### Comments (${comments.length} total${comments.length > 10 ? ', showing last 10' : ''})\n\n${commentsText}`;
|
||||
}
|
||||
|
||||
const hasWorkInProgress =
|
||||
linkedPRs && linkedPRs.some((pr) => pr.state === 'open' || pr.state === 'OPEN');
|
||||
const workInProgressNote = hasWorkInProgress
|
||||
? '\n\n**Note:** This issue has an open pull request linked. Consider that someone may already be working on a fix.'
|
||||
: '';
|
||||
|
||||
return `Please validate the following GitHub issue by analyzing the codebase:
|
||||
|
||||
## Issue #${issueNumber}: ${issueTitle}
|
||||
${labelsSection}
|
||||
${linkedPRsSection}
|
||||
|
||||
### Description
|
||||
|
||||
${issueBody || '(No description provided)'}
|
||||
${commentsSection}
|
||||
${workInProgressNote}
|
||||
|
||||
---
|
||||
|
||||
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.`;
|
||||
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.${comments && comments.length > 0 ? ' Consider the context provided in the comments as well.' : ''}${hasWorkInProgress ? ' Also note in your analysis if there is already work in progress on this issue.' : ''}`;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
/**
|
||||
* Health check routes
|
||||
*
|
||||
* NOTE: Only the basic health check (/) and environment check are unauthenticated.
|
||||
* The /detailed endpoint requires authentication.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createIndexHandler } from './routes/index.js';
|
||||
import { createDetailedHandler } from './routes/detailed.js';
|
||||
import { createEnvironmentHandler } from './routes/environment.js';
|
||||
|
||||
/**
|
||||
* Create unauthenticated health routes (basic check only)
|
||||
* Used by load balancers and container orchestration
|
||||
*/
|
||||
export function createHealthRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Basic health check - no sensitive info
|
||||
router.get('/', createIndexHandler());
|
||||
router.get('/detailed', createDetailedHandler());
|
||||
|
||||
// Environment info including containerization status
|
||||
// This is unauthenticated so the UI can check on startup
|
||||
router.get('/environment', createEnvironmentHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Re-export detailed handler for use in authenticated routes
|
||||
export { createDetailedHandler } from './routes/detailed.js';
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getAuthStatus } from '../../../lib/auth.js';
|
||||
import { getVersion } from '../../../lib/version.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',
|
||||
version: getVersion(),
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
dataDir: process.env.DATA_DIR || './data',
|
||||
|
||||
20
apps/server/src/routes/health/routes/environment.ts
Normal file
20
apps/server/src/routes/health/routes/environment.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* GET /environment endpoint - Environment information including containerization status
|
||||
*
|
||||
* This endpoint is unauthenticated so the UI can check it on startup
|
||||
* before login to determine if sandbox risk warnings should be shown.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
export interface EnvironmentResponse {
|
||||
isContainerized: boolean;
|
||||
}
|
||||
|
||||
export function createEnvironmentHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
isContainerized: process.env.IS_CONTAINERIZED === 'true',
|
||||
} satisfies EnvironmentResponse);
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getVersion } from '../../../lib/version.js';
|
||||
|
||||
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',
|
||||
version: getVersion(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
20
apps/server/src/routes/mcp/common.ts
Normal file
20
apps/server/src/routes/mcp/common.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Common utilities for MCP routes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with prefix
|
||||
*/
|
||||
export function logError(error: unknown, message: string): void {
|
||||
console.error(`[MCP] ${message}:`, error);
|
||||
}
|
||||
36
apps/server/src/routes/mcp/index.ts
Normal file
36
apps/server/src/routes/mcp/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* MCP routes - HTTP API for testing MCP servers
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Testing MCP server connections
|
||||
* - Listing available tools from MCP servers
|
||||
*
|
||||
* Mounted at /api/mcp in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { MCPTestService } from '../../services/mcp-test-service.js';
|
||||
import { createTestServerHandler } from './routes/test-server.js';
|
||||
import { createListToolsHandler } from './routes/list-tools.js';
|
||||
|
||||
/**
|
||||
* Create MCP router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /test - Test MCP server connection
|
||||
* - POST /tools - List tools from MCP server
|
||||
*
|
||||
* @param mcpTestService - Instance of MCPTestService for testing connections
|
||||
* @returns Express Router configured with all MCP endpoints
|
||||
*/
|
||||
export function createMCPRoutes(mcpTestService: MCPTestService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Test MCP server connection
|
||||
router.post('/test', createTestServerHandler(mcpTestService));
|
||||
|
||||
// List tools from MCP server
|
||||
router.post('/tools', createListToolsHandler(mcpTestService));
|
||||
|
||||
return router;
|
||||
}
|
||||
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* POST /api/mcp/tools - List tools for an MCP server
|
||||
*
|
||||
* Lists available tools for an MCP server.
|
||||
* Similar to test but focused on tool discovery.
|
||||
*
|
||||
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
|
||||
* arbitrary serverConfig to prevent drive-by command execution attacks.
|
||||
* Users must explicitly save a server config through the UI before testing.
|
||||
*
|
||||
* Request body:
|
||||
* { serverId: string } - Get tools by server ID from settings
|
||||
*
|
||||
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { MCPTestService } from '../../../services/mcp-test-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ListToolsRequest {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/mcp/tools
|
||||
*/
|
||||
export function createListToolsHandler(mcpTestService: MCPTestService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body as ListToolsRequest;
|
||||
|
||||
if (!body.serverId || typeof body.serverId !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'serverId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await mcpTestService.testServerById(body.serverId);
|
||||
|
||||
// Return only tool-related information
|
||||
res.json({
|
||||
success: result.success,
|
||||
tools: result.tools,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List tools failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/mcp/test - Test MCP server connection and list tools
|
||||
*
|
||||
* Tests connection to an MCP server and returns available tools.
|
||||
*
|
||||
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
|
||||
* arbitrary serverConfig to prevent drive-by command execution attacks.
|
||||
* Users must explicitly save a server config through the UI before testing.
|
||||
*
|
||||
* Request body:
|
||||
* { serverId: string } - Test server by ID from settings
|
||||
*
|
||||
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { MCPTestService } from '../../../services/mcp-test-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface TestServerRequest {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/mcp/test
|
||||
*/
|
||||
export function createTestServerHandler(mcpTestService: MCPTestService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body as TestServerRequest;
|
||||
|
||||
if (!body.serverId || typeof body.serverId !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'serverId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await mcpTestService.testServerById(body.serverId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Test server failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,8 +9,7 @@ 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();
|
||||
const runningAgents = await autoModeService.getRunningAgents();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
@@ -35,36 +35,13 @@ export function getAllApiKeys(): Record<string, string> {
|
||||
|
||||
/**
|
||||
* Helper to persist API keys to .env file
|
||||
* Uses centralized secureFs.writeEnvKey for path validation
|
||||
*/
|
||||
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'));
|
||||
await secureFs.writeEnvKey(envPath, key, value);
|
||||
logger.info(`[Setup] Persisted ${key} to .env file`);
|
||||
} catch (error) {
|
||||
logger.error(`[Setup] Failed to persist ${key} to .env:`, error);
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
|
||||
import { getApiKey } from './common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -37,42 +35,25 @@ export async function getClaudeStatus() {
|
||||
// Version command might not be available
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH, try common locations based on platform
|
||||
const commonPaths = isWindows
|
||||
? (() => {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
return [
|
||||
// Windows-specific paths
|
||||
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
|
||||
path.join(appData, 'npm', 'claude.cmd'),
|
||||
path.join(appData, 'npm', 'claude'),
|
||||
path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
|
||||
path.join(appData, '.npm-global', 'bin', 'claude'),
|
||||
];
|
||||
})()
|
||||
: [
|
||||
// Unix (Linux/macOS) paths
|
||||
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'),
|
||||
];
|
||||
// Not in PATH, try common locations from centralized system paths
|
||||
const commonPaths = getClaudeCliPaths();
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
cliPath = p;
|
||||
installed = true;
|
||||
method = 'local';
|
||||
if (await systemPathAccess(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
|
||||
// Get version from this path
|
||||
try {
|
||||
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
|
||||
version = versionOut.trim();
|
||||
} catch {
|
||||
// Version command might not be available
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// Not found at this path
|
||||
}
|
||||
@@ -82,7 +63,7 @@ export async function getClaudeStatus() {
|
||||
// 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 = {
|
||||
const auth = {
|
||||
authenticated: false,
|
||||
method: 'none' as string,
|
||||
hasCredentialsFile: false,
|
||||
@@ -97,76 +78,36 @@ export async function getClaudeStatus() {
|
||||
hasRecentActivity: false,
|
||||
};
|
||||
|
||||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
// Use centralized system paths to check Claude authentication indicators
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
|
||||
// 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 for recent activity (indicates working authentication)
|
||||
if (indicators.hasStatsCacheWithActivity) {
|
||||
auth.hasRecentActivity = true;
|
||||
auth.hasCliAuth = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Check for settings + sessions (indicates CLI is set up)
|
||||
if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) {
|
||||
auth.hasCliAuth = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
|
||||
// Check credentials file
|
||||
if (indicators.hasCredentialsFile && indicators.credentials) {
|
||||
auth.hasCredentialsFile = true;
|
||||
if (indicators.credentials.hasOAuthToken) {
|
||||
auth.hasStoredOAuthToken = true;
|
||||
auth.oauthTokenValid = 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)
|
||||
// Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform
|
||||
const credentialsPaths = [
|
||||
path.join(claudeDir, '.credentials.json'),
|
||||
path.join(claudeDir, 'credentials.json'),
|
||||
];
|
||||
|
||||
for (const credentialsPath of credentialsPaths) {
|
||||
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
|
||||
}
|
||||
break; // Found and processed credentials file
|
||||
} catch {
|
||||
// No credentials file at this path or invalid format
|
||||
auth.method = 'oauth_token';
|
||||
} else if (indicators.credentials.hasApiKey) {
|
||||
auth.apiKeyValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'api_key';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,21 +115,21 @@ export async function getClaudeStatus() {
|
||||
if (auth.hasEnvApiKey) {
|
||||
auth.authenticated = true;
|
||||
auth.apiKeyValid = true;
|
||||
auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var
|
||||
auth.method = 'api_key_env';
|
||||
}
|
||||
|
||||
// In-memory stored OAuth token (from setup wizard - subscription auth)
|
||||
if (!auth.authenticated && getApiKey('anthropic_oauth_token')) {
|
||||
auth.authenticated = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.method = 'oauth_token'; // Stored OAuth token from setup wizard
|
||||
auth.method = 'oauth_token';
|
||||
}
|
||||
|
||||
// In-memory stored API key (from settings UI - pay-per-use)
|
||||
if (!auth.authenticated && getApiKey('anthropic')) {
|
||||
auth.authenticated = true;
|
||||
auth.apiKeyValid = true;
|
||||
auth.method = 'api_key'; // Manually stored API key
|
||||
auth.method = 'api_key';
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,40 +5,22 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
|
||||
// In-memory storage reference (imported from common.ts pattern)
|
||||
// We need to modify common.ts to export a deleteApiKey function
|
||||
import { setApiKey } from '../common.js';
|
||||
|
||||
/**
|
||||
* Remove an API key from the .env file
|
||||
* Uses centralized secureFs.removeEnvKey for path validation
|
||||
*/
|
||||
async function removeApiKeyFromEnv(key: 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, nothing to delete
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse existing env content and remove the key
|
||||
const lines = envContent.split('\n');
|
||||
const keyRegex = new RegExp(`^${key}=`);
|
||||
const newLines = lines.filter((line) => !keyRegex.test(line));
|
||||
|
||||
// Remove empty lines at the end
|
||||
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') {
|
||||
newLines.pop();
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, newLines.join('\n') + (newLines.length > 0 ? '\n' : ''));
|
||||
await secureFs.removeEnvKey(envPath, key);
|
||||
logger.info(`[Setup] Removed ${key} from .env file`);
|
||||
} catch (error) {
|
||||
logger.error(`[Setup] Failed to remove ${key} from .env:`, error);
|
||||
|
||||
@@ -5,27 +5,14 @@
|
||||
import type { Request, 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';
|
||||
import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
|
||||
const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
PATH: getExtendedPath(),
|
||||
};
|
||||
|
||||
export interface GhStatus {
|
||||
@@ -55,25 +42,16 @@ async function getGhStatus(): Promise<GhStatus> {
|
||||
status.path = stdout.trim().split(/\r?\n/)[0];
|
||||
status.installed = true;
|
||||
} catch {
|
||||
// gh not in PATH, try common locations
|
||||
const commonPaths = isWindows
|
||||
? [
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
|
||||
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
|
||||
]
|
||||
: [
|
||||
'/opt/homebrew/bin/gh',
|
||||
'/usr/local/bin/gh',
|
||||
path.join(os.homedir(), '.local', 'bin', 'gh'),
|
||||
'/home/linuxbrew/.linuxbrew/bin/gh',
|
||||
];
|
||||
// gh not in PATH, try common locations from centralized system paths
|
||||
const commonPaths = getGitHubCliPaths();
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
status.path = p;
|
||||
status.installed = true;
|
||||
break;
|
||||
if (await systemPathAccess(p)) {
|
||||
status.path = p;
|
||||
status.installed = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Not found at this path
|
||||
}
|
||||
@@ -94,23 +72,37 @@ async function getGhStatus(): Promise<GhStatus> {
|
||||
// Version command failed
|
||||
}
|
||||
|
||||
// Check authentication status
|
||||
// Check authentication status by actually making an API call
|
||||
// gh auth status can return non-zero even when GH_TOKEN is valid
|
||||
let apiCallSucceeded = false;
|
||||
try {
|
||||
const { stdout } = await execAsync('gh auth status', { env: execEnv });
|
||||
// If this succeeds without error, we're authenticated
|
||||
status.authenticated = true;
|
||||
|
||||
// Try to extract username from output
|
||||
const userMatch =
|
||||
stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
|
||||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
|
||||
if (userMatch) {
|
||||
status.user = userMatch[1];
|
||||
const { stdout } = await execAsync('gh api user --jq ".login"', { env: execEnv });
|
||||
const user = stdout.trim();
|
||||
if (user) {
|
||||
status.authenticated = true;
|
||||
status.user = user;
|
||||
apiCallSucceeded = true;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Auth status returns non-zero if not authenticated
|
||||
const err = error as { stderr?: string };
|
||||
if (err.stderr?.includes('not logged in')) {
|
||||
// If stdout is empty, fall through to gh auth status fallback
|
||||
} catch {
|
||||
// API call failed - fall through to gh auth status fallback
|
||||
}
|
||||
|
||||
// Fallback: try gh auth status if API call didn't succeed
|
||||
if (!apiCallSucceeded) {
|
||||
try {
|
||||
const { stdout } = await execAsync('gh auth status', { env: execEnv });
|
||||
status.authenticated = true;
|
||||
|
||||
// Try to extract username from output
|
||||
const userMatch =
|
||||
stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
|
||||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
|
||||
if (userMatch) {
|
||||
status.user = userMatch[1];
|
||||
}
|
||||
} catch {
|
||||
// Auth status returns non-zero if not authenticated
|
||||
status.authenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ export function createSessionsListHandler() {
|
||||
}
|
||||
|
||||
export function createSessionsCreateHandler() {
|
||||
return (req: Request, res: Response): void => {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const terminalService = getTerminalService();
|
||||
const { cwd, cols, rows, shell } = req.body;
|
||||
|
||||
const session = terminalService.createSession({
|
||||
const session = await terminalService.createSession({
|
||||
cwd,
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
|
||||
@@ -158,8 +158,13 @@ export const logError = createLogError(logger);
|
||||
/**
|
||||
* Ensure the repository has at least one commit so git commands that rely on HEAD work.
|
||||
* Returns true if an empty commit was created, false if the repo already had commits.
|
||||
* @param repoPath - Path to the git repository
|
||||
* @param env - Optional environment variables to pass to git (e.g., GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL)
|
||||
*/
|
||||
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||
export async function ensureInitialCommit(
|
||||
repoPath: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
|
||||
return false;
|
||||
@@ -167,6 +172,7 @@ export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, {
|
||||
cwd: repoPath,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`);
|
||||
return true;
|
||||
|
||||
@@ -100,7 +100,14 @@ export function createCreateHandler() {
|
||||
}
|
||||
|
||||
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed
|
||||
await ensureInitialCommit(projectPath);
|
||||
// Pass git identity env vars so commits work without global git config
|
||||
const gitEnv = {
|
||||
GIT_AUTHOR_NAME: 'Automaker',
|
||||
GIT_AUTHOR_EMAIL: 'automaker@localhost',
|
||||
GIT_COMMITTER_NAME: 'Automaker',
|
||||
GIT_COMMITTER_EMAIL: 'automaker@localhost',
|
||||
};
|
||||
await ensureInitialCommit(projectPath, gitEnv);
|
||||
|
||||
// First, check if git already has a worktree for this branch (anywhere)
|
||||
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
loadContextFiles,
|
||||
createLogger,
|
||||
} from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
@@ -21,6 +22,9 @@ import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -73,6 +77,7 @@ export class AgentService {
|
||||
private metadataFile: string;
|
||||
private events: EventEmitter;
|
||||
private settingsService: SettingsService | null = null;
|
||||
private logger = createLogger('AgentService');
|
||||
|
||||
constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) {
|
||||
this.stateDir = path.join(dataDir, 'agent-sessions');
|
||||
@@ -146,12 +151,12 @@ export class AgentService {
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.error('[AgentService] ERROR: Session not found:', sessionId);
|
||||
this.logger.error('ERROR: Session not found:', sessionId);
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.isRunning) {
|
||||
console.error('[AgentService] ERROR: Agent already running for session:', sessionId);
|
||||
this.logger.error('ERROR: Agent already running for session:', sessionId);
|
||||
throw new Error('Agent is already processing a message');
|
||||
}
|
||||
|
||||
@@ -173,7 +178,7 @@ export class AgentService {
|
||||
filename: imageData.filename,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
this.logger.error(`Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +232,12 @@ export class AgentService {
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
@@ -238,7 +249,7 @@ export class AgentService {
|
||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
|
||||
// Build combined system prompt with base prompt and context files
|
||||
const baseSystemPrompt = this.getSystemPrompt();
|
||||
const baseSystemPrompt = await this.getSystemPrompt();
|
||||
const combinedSystemPrompt = contextFilesPrompt
|
||||
? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
|
||||
: baseSystemPrompt;
|
||||
@@ -252,6 +263,9 @@ export class AgentService {
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -275,6 +289,9 @@ export class AgentService {
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
@@ -377,7 +394,7 @@ export class AgentService {
|
||||
return { success: false, aborted: true };
|
||||
}
|
||||
|
||||
console.error('[AgentService] Error:', error);
|
||||
this.logger.error('Error:', error);
|
||||
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
@@ -471,7 +488,7 @@ export class AgentService {
|
||||
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
|
||||
await this.updateSessionTimestamp(sessionId);
|
||||
} catch (error) {
|
||||
console.error('[AgentService] Failed to save session:', error);
|
||||
this.logger.error('Failed to save session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,7 +722,7 @@ export class AgentService {
|
||||
try {
|
||||
await secureFs.writeFile(queueFile, JSON.stringify(queue, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('[AgentService] Failed to save queue state:', error);
|
||||
this.logger.error('Failed to save queue state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,7 +771,7 @@ export class AgentService {
|
||||
model: nextPrompt.model,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AgentService] Failed to process queued prompt:', error);
|
||||
this.logger.error('Failed to process queued prompt:', error);
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'queue_error',
|
||||
error: (error as Error).message,
|
||||
@@ -767,38 +784,10 @@ export class AgentService {
|
||||
this.events.emit('agent:stream', { sessionId, ...data });
|
||||
}
|
||||
|
||||
private getSystemPrompt(): string {
|
||||
return `You are an AI assistant helping users build software. You are part of the Automaker application,
|
||||
which is designed to help developers plan, design, and implement software projects autonomously.
|
||||
|
||||
**Feature Storage:**
|
||||
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
|
||||
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
|
||||
|
||||
Your role is to:
|
||||
- Help users define their project requirements and specifications
|
||||
- Ask clarifying questions to better understand their needs
|
||||
- Suggest technical approaches and architectures
|
||||
- Guide them through the development process
|
||||
- Be conversational and helpful
|
||||
- Write, edit, and modify code files as requested
|
||||
- Execute commands and tests
|
||||
- Search and analyze the codebase
|
||||
|
||||
When discussing projects, help users think through:
|
||||
- Core functionality and features
|
||||
- Technical stack choices
|
||||
- Data models and architecture
|
||||
- User experience considerations
|
||||
- Testing strategies
|
||||
|
||||
You have full access to the codebase and can:
|
||||
- Read files to understand existing code
|
||||
- Write new files
|
||||
- Edit existing files
|
||||
- Run bash commands
|
||||
- Search for code patterns
|
||||
- Execute tests and builds`;
|
||||
private async getSystemPrompt(): Promise<string> {
|
||||
// Load from settings (no caching - allows hot reload of custom prompts)
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[AgentService]');
|
||||
return prompts.agent.systemPrompt;
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
|
||||
@@ -37,6 +37,9 @@ import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -65,162 +68,6 @@ interface PlanSpec {
|
||||
tasks?: ParsedTask[];
|
||||
}
|
||||
|
||||
const PLANNING_PROMPTS = {
|
||||
lite: `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[PLAN_GENERATED] Planning outline complete."
|
||||
|
||||
Then proceed with implementation.`,
|
||||
|
||||
lite_with_approval: `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.`,
|
||||
|
||||
spec: `## Specification Phase (Spec Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a specification with an actionable task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem**: What problem are we solving? (user perspective)
|
||||
|
||||
2. **Solution**: Brief approach (1-2 sentences)
|
||||
|
||||
3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format
|
||||
- GIVEN [context], WHEN [action], THEN [outcome]
|
||||
|
||||
4. **Files to Modify**:
|
||||
| File | Purpose | Action |
|
||||
|------|---------|--------|
|
||||
| path/to/file | description | create/modify/delete |
|
||||
|
||||
5. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential: T001, T002, T003, etc.
|
||||
- Description: Clear action (e.g., "Create user model", "Add API endpoint")
|
||||
- File: Primary file affected (helps with context)
|
||||
- Order by dependencies (foundational tasks first)
|
||||
|
||||
6. **Verification**: How to confirm feature works
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY in order. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
This allows real-time progress tracking during implementation.`,
|
||||
|
||||
full: `## Full Specification Phase (Full SDD Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem Statement**: 2-3 sentences from user perspective
|
||||
|
||||
2. **User Story**: As a [user], I want [goal], so that [benefit]
|
||||
|
||||
3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN
|
||||
- **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome]
|
||||
- **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling]
|
||||
- **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response]
|
||||
|
||||
4. **Technical Context**:
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Affected Files | list of files |
|
||||
| Dependencies | external libs if any |
|
||||
| Constraints | technical limitations |
|
||||
| Patterns to Follow | existing patterns in codebase |
|
||||
|
||||
5. **Non-Goals**: What this feature explicitly does NOT include
|
||||
|
||||
6. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
## Phase 1: Foundation
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 2: Core Implementation
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
- [ ] T004: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 3: Integration & Testing
|
||||
- [ ] T005: [Description] | File: [path/to/file]
|
||||
- [ ] T006: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential across all phases: T001, T002, T003, etc.
|
||||
- Description: Clear action verb + target
|
||||
- File: Primary file affected
|
||||
- Order by dependencies within each phase
|
||||
- Phase structure helps organize complex work
|
||||
|
||||
7. **Success Metrics**: How we know it's done (measurable criteria)
|
||||
|
||||
8. **Risks & Mitigations**:
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| description | approach |
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY by phase. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
After completing all tasks in a phase, output:
|
||||
"[PHASE_COMPLETE] Phase N complete"
|
||||
|
||||
This allows real-time progress tracking during implementation.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse tasks from generated spec content
|
||||
* Looks for the ```tasks code block and extracts task lines
|
||||
@@ -343,6 +190,10 @@ interface AutoModeConfig {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
// Constants for consecutive failure tracking
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||
|
||||
export class AutoModeService {
|
||||
private events: EventEmitter;
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
@@ -353,12 +204,89 @@ export class AutoModeService {
|
||||
private config: AutoModeConfig | null = null;
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
private settingsService: SettingsService | null = null;
|
||||
// Track consecutive failures to detect quota/API issues
|
||||
private consecutiveFailures: { timestamp: number; error: string }[] = [];
|
||||
private pausedDueToFailures = false;
|
||||
|
||||
constructor(events: EventEmitter, settingsService?: SettingsService) {
|
||||
this.events = events;
|
||||
this.settingsService = settingsService ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures.
|
||||
* This handles cases where the SDK doesn't return useful error messages.
|
||||
*/
|
||||
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
// Add this failure
|
||||
this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
||||
|
||||
// Remove old failures outside the window
|
||||
this.consecutiveFailures = this.consecutiveFailures.filter(
|
||||
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
||||
);
|
||||
|
||||
// Check if we've hit the threshold
|
||||
if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
||||
return true; // Should pause
|
||||
}
|
||||
|
||||
// Also immediately pause for known quota/rate limit errors
|
||||
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that we should pause due to repeated failures or quota exhaustion.
|
||||
* This will pause the auto loop to prevent repeated failures.
|
||||
*/
|
||||
private signalShouldPause(errorInfo: { type: string; message: string }): void {
|
||||
if (this.pausedDueToFailures) {
|
||||
return; // Already paused
|
||||
}
|
||||
|
||||
this.pausedDueToFailures = true;
|
||||
const failureCount = this.consecutiveFailures.length;
|
||||
console.log(
|
||||
`[AutoMode] Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
|
||||
);
|
||||
|
||||
// Emit event to notify UI
|
||||
this.emitAutoModeEvent('auto_mode_paused_failures', {
|
||||
message:
|
||||
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
||||
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
|
||||
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
|
||||
errorType: errorInfo.type,
|
||||
originalError: errorInfo.message,
|
||||
failureCount,
|
||||
projectPath: this.config?.projectPath,
|
||||
});
|
||||
|
||||
// Stop the auto loop
|
||||
this.stopAutoLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure tracking (called when user manually restarts auto mode)
|
||||
*/
|
||||
private resetFailureTracking(): void {
|
||||
this.consecutiveFailures = [];
|
||||
this.pausedDueToFailures = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful feature completion to reset consecutive failure count
|
||||
*/
|
||||
private recordSuccess(): void {
|
||||
this.consecutiveFailures = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop - continuously picks and executes pending features
|
||||
*/
|
||||
@@ -367,6 +295,9 @@ export class AutoModeService {
|
||||
throw new Error('Auto mode is already running');
|
||||
}
|
||||
|
||||
// Reset failure tracking when user manually starts auto mode
|
||||
this.resetFailureTracking();
|
||||
|
||||
this.autoLoopRunning = true;
|
||||
this.autoLoopAbortController = new AbortController();
|
||||
this.config = {
|
||||
@@ -591,7 +522,7 @@ export class AutoModeService {
|
||||
} else {
|
||||
// Normal flow: build prompt with planning phase
|
||||
const featurePrompt = this.buildFeaturePrompt(feature);
|
||||
const planningPrefix = this.getPlanningPromptPrefix(feature);
|
||||
const planningPrefix = await this.getPlanningPromptPrefix(feature);
|
||||
prompt = planningPrefix + featurePrompt;
|
||||
|
||||
// Emit planning mode info
|
||||
@@ -655,6 +586,9 @@ export class AutoModeService {
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
|
||||
// Record success to reset consecutive failure tracking
|
||||
this.recordSuccess();
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
@@ -682,6 +616,21 @@ export class AutoModeService {
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Track this failure and check if we should pause auto mode
|
||||
// This handles both specific quota/rate limit errors AND generic failures
|
||||
// that may indicate quota exhaustion (SDK doesn't always return useful errors)
|
||||
const shouldPause = this.trackFailureAndCheckPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
|
||||
if (shouldPause) {
|
||||
this.signalShouldPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`);
|
||||
@@ -842,6 +791,11 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
this.cancelPlanApproval(featureId);
|
||||
|
||||
running.abortController.abort();
|
||||
|
||||
// Remove from running features immediately to allow resume
|
||||
// The abort signal will still propagate to stop any ongoing execution
|
||||
this.runningFeatures.delete(featureId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1079,6 +1033,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
|
||||
// Record success to reset consecutive failure tracking
|
||||
this.recordSuccess();
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
@@ -1094,6 +1051,19 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Track this failure and check if we should pause auto mode
|
||||
const shouldPause = this.trackFailureAndCheckPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
|
||||
if (shouldPause) {
|
||||
this.signalShouldPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
@@ -1372,18 +1342,43 @@ Format your response as a structured markdown document.`;
|
||||
/**
|
||||
* Get detailed info about all running agents
|
||||
*/
|
||||
getRunningAgents(): Array<{
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
}> {
|
||||
return Array.from(this.runningFeatures.values()).map((rf) => ({
|
||||
featureId: rf.featureId,
|
||||
projectPath: rf.projectPath,
|
||||
projectName: path.basename(rf.projectPath),
|
||||
isAutoMode: rf.isAutoMode,
|
||||
}));
|
||||
async getRunningAgents(): Promise<
|
||||
Array<{
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>
|
||||
> {
|
||||
const agents = await Promise.all(
|
||||
Array.from(this.runningFeatures.values()).map(async (rf) => {
|
||||
// Try to fetch feature data to get title and description
|
||||
let title: string | undefined;
|
||||
let description: string | undefined;
|
||||
|
||||
try {
|
||||
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
|
||||
if (feature) {
|
||||
title = feature.title;
|
||||
description = feature.description;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors - title/description are optional
|
||||
}
|
||||
|
||||
return {
|
||||
featureId: rf.featureId,
|
||||
projectPath: rf.projectPath,
|
||||
projectName: path.basename(rf.projectPath),
|
||||
isAutoMode: rf.isAutoMode,
|
||||
title,
|
||||
description,
|
||||
};
|
||||
})
|
||||
);
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1757,20 +1752,29 @@ Format your response as a structured markdown document.`;
|
||||
/**
|
||||
* Get the planning prompt prefix based on feature's planning mode
|
||||
*/
|
||||
private getPlanningPromptPrefix(feature: Feature): string {
|
||||
private async getPlanningPromptPrefix(feature: Feature): Promise<string> {
|
||||
const mode = feature.planningMode || 'skip';
|
||||
|
||||
if (mode === 'skip') {
|
||||
return ''; // No planning phase
|
||||
}
|
||||
|
||||
// Load prompts from settings (no caching - allows hot reload of custom prompts)
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||
const planningPrompts: Record<string, string> = {
|
||||
lite: prompts.autoMode.planningLite,
|
||||
lite_with_approval: prompts.autoMode.planningLiteWithApproval,
|
||||
spec: prompts.autoMode.planningSpec,
|
||||
full: prompts.autoMode.planningFull,
|
||||
};
|
||||
|
||||
// For lite mode, use the approval variant if requirePlanApproval is true
|
||||
let promptKey: string = mode;
|
||||
if (mode === 'lite' && feature.requirePlanApproval === true) {
|
||||
promptKey = 'lite_with_approval';
|
||||
}
|
||||
|
||||
const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS];
|
||||
const planningPrompt = planningPrompts[promptKey];
|
||||
if (!planningPrompt) {
|
||||
return '';
|
||||
}
|
||||
@@ -1996,6 +2000,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
// Load enableSandboxMode setting (global setting only)
|
||||
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Build SDK options using centralized configuration for feature implementation
|
||||
const sdkOptions = createAutoModeOptions({
|
||||
cwd: workDir,
|
||||
@@ -2003,6 +2013,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -2044,10 +2057,15 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
};
|
||||
|
||||
// Execute via provider
|
||||
console.log(`[AutoMode] Starting stream for feature ${featureId}...`);
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
console.log(`[AutoMode] Stream created, starting to iterate...`);
|
||||
// Initialize with previous content if this is a follow-up, with a separator
|
||||
let responseText = previousContent
|
||||
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
|
||||
@@ -2085,6 +2103,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
};
|
||||
|
||||
streamLoop: for await (const msg of stream) {
|
||||
console.log(`[AutoMode] Stream message received:`, msg.type, msg.subtype || '');
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
@@ -2271,6 +2290,9 @@ After generating the revised spec, output:
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
let revisionText = '';
|
||||
@@ -2408,6 +2430,9 @@ After generating the revised spec, output:
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
let taskOutput = '';
|
||||
@@ -2497,6 +2522,9 @@ Implement all the changes described in the plan above.`;
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
for await (const msg of continuationStream) {
|
||||
@@ -2531,6 +2559,9 @@ Implement all the changes described in the plan above.`;
|
||||
|
||||
// Only emit progress for non-marker text (marker was already handled above)
|
||||
if (!specDetected) {
|
||||
console.log(
|
||||
`[AutoMode] Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}`
|
||||
);
|
||||
this.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
content: block.text,
|
||||
|
||||
@@ -12,12 +12,13 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
*
|
||||
* Platform-specific implementations:
|
||||
* - macOS: Uses 'expect' command for PTY
|
||||
* - Windows: Uses node-pty for PTY
|
||||
* - Windows/Linux: Uses node-pty for PTY
|
||||
*/
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = 'claude';
|
||||
private timeout = 30000; // 30 second timeout
|
||||
private isWindows = os.platform() === 'win32';
|
||||
private isLinux = os.platform() === 'linux';
|
||||
|
||||
/**
|
||||
* Check if Claude CLI is available on the system
|
||||
@@ -48,8 +49,8 @@ export class ClaudeUsageService {
|
||||
* Uses platform-specific PTY implementation
|
||||
*/
|
||||
private executeClaudeUsageCommand(): Promise<string> {
|
||||
if (this.isWindows) {
|
||||
return this.executeClaudeUsageCommandWindows();
|
||||
if (this.isWindows || this.isLinux) {
|
||||
return this.executeClaudeUsageCommandPty();
|
||||
}
|
||||
return this.executeClaudeUsageCommandMac();
|
||||
}
|
||||
@@ -147,17 +148,23 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows implementation using node-pty
|
||||
* Windows/Linux implementation using node-pty
|
||||
*/
|
||||
private executeClaudeUsageCommandWindows(): Promise<string> {
|
||||
private executeClaudeUsageCommandPty(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
let settled = false;
|
||||
let hasSeenUsageData = false;
|
||||
|
||||
const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\';
|
||||
const workingDirectory = this.isWindows
|
||||
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
||||
: process.env.HOME || os.homedir() || '/tmp';
|
||||
|
||||
const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], {
|
||||
// Use platform-appropriate shell and command
|
||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||
|
||||
const ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
@@ -172,7 +179,12 @@ export class ClaudeUsageService {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ptyProcess.kill();
|
||||
reject(new Error('Command timed out'));
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
resolve(output);
|
||||
} else {
|
||||
reject(new Error('Command timed out'));
|
||||
}
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
@@ -186,6 +198,13 @@ export class ClaudeUsageService {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
|
||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
ptyProcess.kill('SIGTERM');
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -185,9 +185,8 @@ export class FeatureLoader {
|
||||
})) as any[];
|
||||
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
||||
|
||||
// Load each feature
|
||||
const features: Feature[] = [];
|
||||
for (const dir of featureDirs) {
|
||||
// Load all features concurrently (secureFs has built-in concurrency limiting)
|
||||
const featurePromises = featureDirs.map(async (dir) => {
|
||||
const featureId = dir.name;
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
|
||||
@@ -199,13 +198,13 @@ export class FeatureLoader {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
|
||||
);
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
|
||||
features.push(feature);
|
||||
return feature as Feature;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
continue;
|
||||
return null;
|
||||
} else if (error instanceof SyntaxError) {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
|
||||
@@ -216,8 +215,12 @@ export class FeatureLoader {
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(featurePromises);
|
||||
const features = results.filter((f): f is Feature => f !== null);
|
||||
|
||||
// Sort by creation order (feature IDs contain timestamp)
|
||||
features.sort((a, b) => {
|
||||
|
||||
251
apps/server/src/services/mcp-test-service.ts
Normal file
251
apps/server/src/services/mcp-test-service.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MCP Test Service
|
||||
*
|
||||
* Provides functionality to test MCP server connections and list available tools.
|
||||
* Supports stdio, SSE, and HTTP transport types.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
export interface MCPTestResult {
|
||||
success: boolean;
|
||||
tools?: MCPToolInfo[];
|
||||
error?: string;
|
||||
connectionTime?: number;
|
||||
serverInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Test Service for testing server connections and listing tools
|
||||
*/
|
||||
export class MCPTestService {
|
||||
private settingsService: SettingsService;
|
||||
|
||||
constructor(settingsService: SettingsService) {
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to an MCP server and list its tools
|
||||
*/
|
||||
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
|
||||
const startTime = Date.now();
|
||||
let client: Client | null = null;
|
||||
let transport:
|
||||
| StdioClientTransport
|
||||
| SSEClientTransport
|
||||
| StreamableHTTPClientTransport
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
client = new Client({
|
||||
name: 'automaker-mcp-test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Create transport based on server type
|
||||
transport = await this.createTransport(serverConfig);
|
||||
|
||||
// Connect with timeout
|
||||
await Promise.race([
|
||||
client.connect(transport),
|
||||
this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'),
|
||||
]);
|
||||
|
||||
// List tools with timeout
|
||||
const toolsResult = await Promise.race([
|
||||
client.listTools(),
|
||||
this.timeout<{
|
||||
tools: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
}>;
|
||||
}>(DEFAULT_TIMEOUT, 'List tools timeout'),
|
||||
]);
|
||||
|
||||
const connectionTime = Date.now() - startTime;
|
||||
|
||||
// Convert tools to MCPToolInfo format
|
||||
const tools: MCPToolInfo[] = (toolsResult.tools || []).map(
|
||||
(tool: { name: string; description?: string; inputSchema?: Record<string, unknown> }) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tools,
|
||||
connectionTime,
|
||||
serverInfo: {
|
||||
name: serverConfig.name,
|
||||
version: undefined, // Could be extracted from server info if available
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const connectionTime = Date.now() - startTime;
|
||||
return {
|
||||
success: false,
|
||||
error: this.getErrorMessage(error),
|
||||
connectionTime,
|
||||
};
|
||||
} finally {
|
||||
// Clean up client connection and ensure process termination
|
||||
await this.cleanupConnection(client, transport);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up MCP client connection and terminate spawned processes
|
||||
*
|
||||
* On Windows, child processes spawned via 'cmd /c' don't get terminated when the
|
||||
* parent process is killed. We use taskkill with /t flag to kill the entire process tree.
|
||||
* This prevents orphaned MCP server processes that would spam logs with ping warnings.
|
||||
*
|
||||
* IMPORTANT: We must run taskkill BEFORE client.close() because:
|
||||
* - client.close() kills only the parent cmd.exe process
|
||||
* - This orphans the child node.exe processes before we can kill them
|
||||
* - taskkill /t needs the parent PID to exist to traverse the process tree
|
||||
*/
|
||||
private async cleanupConnection(
|
||||
client: Client | null,
|
||||
transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null
|
||||
): Promise<void> {
|
||||
// Get the PID before any cleanup (only available for stdio transports)
|
||||
const pid = transport instanceof StdioClientTransport ? transport.pid : null;
|
||||
|
||||
// On Windows with stdio transport, kill the entire process tree FIRST
|
||||
// This must happen before client.close() which would orphan child processes
|
||||
if (IS_WINDOWS && pid) {
|
||||
try {
|
||||
// taskkill /f = force, /t = kill process tree, /pid = process ID
|
||||
await execAsync(`taskkill /f /t /pid ${pid}`);
|
||||
} catch {
|
||||
// Process may have already exited, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
// Now do the standard close (may be a no-op if taskkill already killed everything)
|
||||
if (client) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// Expected if taskkill already terminated the process
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test server by ID (looks up config from settings)
|
||||
*/
|
||||
async testServerById(serverId: string): Promise<MCPTestResult> {
|
||||
try {
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId);
|
||||
|
||||
if (!serverConfig) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Server with ID "${serverId}" not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return this.testServer(serverConfig);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create appropriate transport based on server type
|
||||
*/
|
||||
private async createTransport(
|
||||
config: MCPServerConfig
|
||||
): Promise<StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport> {
|
||||
if (config.type === 'sse') {
|
||||
if (!config.url) {
|
||||
throw new Error('URL is required for SSE transport');
|
||||
}
|
||||
// Use eventSourceInit workaround for SSE headers (SDK bug workaround)
|
||||
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436
|
||||
const headers = config.headers;
|
||||
return new SSEClientTransport(new URL(config.url), {
|
||||
requestInit: headers ? { headers } : undefined,
|
||||
eventSourceInit: headers
|
||||
? {
|
||||
fetch: (url: string | URL | Request, init?: RequestInit) => {
|
||||
const fetchHeaders = new Headers(init?.headers || {});
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
fetchHeaders.set(key, value);
|
||||
}
|
||||
return fetch(url, { ...init, headers: fetchHeaders });
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.type === 'http') {
|
||||
if (!config.url) {
|
||||
throw new Error('URL is required for HTTP transport');
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers
|
||||
? {
|
||||
headers: config.headers,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Default to stdio
|
||||
if (!config.command) {
|
||||
throw new Error('Command is required for stdio transport');
|
||||
}
|
||||
|
||||
return new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout promise
|
||||
*/
|
||||
private timeout<T>(ms: number, message: string): Promise<T> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(message)), ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
private getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,8 @@ export class SettingsService {
|
||||
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
|
||||
* compatibility during schema migrations.
|
||||
*
|
||||
* Also applies version-based migrations for breaking changes.
|
||||
*
|
||||
* @returns Promise resolving to complete GlobalSettings object
|
||||
*/
|
||||
async getGlobalSettings(): Promise<GlobalSettings> {
|
||||
@@ -131,7 +133,7 @@ export class SettingsService {
|
||||
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
return {
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
keyboardShortcuts: {
|
||||
@@ -139,6 +141,32 @@ export class SettingsService {
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
};
|
||||
|
||||
// Version-based migrations
|
||||
const storedVersion = settings.version || 1;
|
||||
let needsSave = false;
|
||||
|
||||
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
|
||||
// Sandbox mode can cause issues on some systems, so we're disabling it by default
|
||||
if (storedVersion < 2) {
|
||||
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
|
||||
result.enableSandboxMode = false;
|
||||
result.version = SETTINGS_VERSION;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Save migrated settings if needed
|
||||
if (needsSave) {
|
||||
try {
|
||||
await ensureDataDir(this.dataDir);
|
||||
await atomicWriteJson(settingsPath, result);
|
||||
logger.info('Settings migration complete');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save migrated settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,18 @@
|
||||
import * as pty from 'node-pty';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
// secureFs is used for user-controllable paths (working directory validation)
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
|
||||
import {
|
||||
systemPathExists,
|
||||
systemPathReadFileSync,
|
||||
getWslVersionPath,
|
||||
getShellPaths,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
@@ -60,60 +70,96 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Detect the best shell for the current platform
|
||||
* Uses getShellPaths() to iterate through allowed shell paths
|
||||
*/
|
||||
detectShell(): { shell: string; args: string[] } {
|
||||
const platform = os.platform();
|
||||
const shellPaths = getShellPaths();
|
||||
|
||||
// Check if running in WSL
|
||||
// Helper to get basename handling both path separators
|
||||
const getBasename = (shellPath: string): string => {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
};
|
||||
|
||||
// Helper to get shell args based on shell name
|
||||
const getShellArgs = (shell: string): string[] => {
|
||||
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
|
||||
// PowerShell and cmd don't need --login
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
// sh doesn't support --login in all implementations
|
||||
if (shellName === 'sh') {
|
||||
return [];
|
||||
}
|
||||
// bash, zsh, and other POSIX shells support --login
|
||||
return ['--login'];
|
||||
};
|
||||
|
||||
// Check if running in WSL - prefer user's shell or bash with --login
|
||||
if (platform === 'linux' && this.isWSL()) {
|
||||
// In WSL, prefer the user's configured shell or bash
|
||||
const userShell = process.env.SHELL || '/bin/bash';
|
||||
if (fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell) {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to first available POSIX shell
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue
|
||||
}
|
||||
}
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'win32': {
|
||||
// Windows: prefer PowerShell, fall back to cmd
|
||||
const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
|
||||
if (fs.existsSync(pwshCore)) {
|
||||
return { shell: pwshCore, args: [] };
|
||||
// For all platforms: first try user's shell if set
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && platform !== 'win32') {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(pwsh)) {
|
||||
return { shell: pwsh, args: [] };
|
||||
}
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
|
||||
case 'darwin': {
|
||||
// macOS: prefer user's shell, then zsh, then bash
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync('/bin/zsh')) {
|
||||
return { shell: '/bin/zsh', args: ['--login'] };
|
||||
}
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
case 'linux':
|
||||
default: {
|
||||
// Linux: prefer user's shell, then bash, then sh
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync('/bin/bash')) {
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through allowed shell paths and return first existing one
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed or doesn't exist, continue to next
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallbacks based on platform
|
||||
if (platform === 'win32') {
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,8 +168,9 @@ export class TerminalService extends EventEmitter {
|
||||
isWSL(): boolean {
|
||||
try {
|
||||
// Check /proc/version for Microsoft/WSL indicators
|
||||
if (fs.existsSync('/proc/version')) {
|
||||
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
|
||||
const wslVersionPath = getWslVersionPath();
|
||||
if (systemPathExists(wslVersionPath)) {
|
||||
const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase();
|
||||
return version.includes('microsoft') || version.includes('wsl');
|
||||
}
|
||||
// Check for WSL environment variable
|
||||
@@ -157,8 +204,9 @@ export class TerminalService extends EventEmitter {
|
||||
/**
|
||||
* Validate and resolve a working directory path
|
||||
* Includes basic sanitization against null bytes and path normalization
|
||||
* Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths
|
||||
*/
|
||||
private resolveWorkingDirectory(requestedCwd?: string): string {
|
||||
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// If no cwd requested, use home
|
||||
@@ -187,15 +235,19 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
// Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
// This prevents terminals from being opened in directories outside the allowed workspace
|
||||
try {
|
||||
const stat = fs.statSync(cwd);
|
||||
if (stat.isDirectory()) {
|
||||
const statResult = await secureFs.stat(cwd);
|
||||
if (statResult.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
} catch {
|
||||
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
|
||||
console.warn(
|
||||
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
|
||||
);
|
||||
return homeDir;
|
||||
}
|
||||
}
|
||||
@@ -228,7 +280,7 @@ export class TerminalService extends EventEmitter {
|
||||
* Create a new terminal session
|
||||
* Returns null if the maximum session limit has been reached
|
||||
*/
|
||||
createSession(options: TerminalOptions = {}): TerminalSession | null {
|
||||
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
@@ -241,12 +293,23 @@ export class TerminalService extends EventEmitter {
|
||||
const shell = options.shell || detectedShell;
|
||||
|
||||
// Validate and resolve working directory
|
||||
const cwd = this.resolveWorkingDirectory(options.cwd);
|
||||
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
||||
const cwd = await this.resolveWorkingDirectory(options.cwd);
|
||||
|
||||
// Build environment with some useful defaults
|
||||
// These settings ensure consistent terminal behavior across platforms
|
||||
// First, create a clean copy of process.env excluding Automaker-specific variables
|
||||
// that could pollute user shells (e.g., PORT would affect Next.js/other dev servers)
|
||||
const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH'];
|
||||
const cleanEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !automakerEnvVars.includes(key)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
...cleanEnv,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'automaker-terminal',
|
||||
|
||||
@@ -22,13 +22,21 @@ export async function createTestGitRepo(): Promise<TestRepo> {
|
||||
|
||||
// Initialize git repo
|
||||
await execAsync('git init', { cwd: tmpDir });
|
||||
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
|
||||
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
||||
|
||||
// Use environment variables instead of git config to avoid affecting user's git config
|
||||
// These env vars override git config without modifying it
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test User',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'Test User',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
};
|
||||
|
||||
// Create initial commit
|
||||
await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n');
|
||||
await execAsync('git add .', { cwd: tmpDir });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
|
||||
|
||||
// Create main branch explicitly
|
||||
await execAsync('git branch -M main', { cwd: tmpDir });
|
||||
|
||||
@@ -15,10 +15,8 @@ describe('worktree create route - repositories without commits', () => {
|
||||
async function initRepoWithoutCommit() {
|
||||
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
|
||||
await execAsync('git init', { cwd: repoPath });
|
||||
await execAsync('git config user.email "test@example.com"', {
|
||||
cwd: repoPath,
|
||||
});
|
||||
await execAsync('git config user.name "Test User"', { cwd: repoPath });
|
||||
// Don't set git config - use environment variables in commit operations instead
|
||||
// to avoid affecting user's git config
|
||||
// Intentionally skip creating an initial commit
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
|
||||
@@ -8,26 +10,13 @@ import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
describe('auth.ts', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
delete process.env.AUTOMAKER_HIDE_API_KEY;
|
||||
delete process.env.NODE_ENV;
|
||||
});
|
||||
|
||||
describe('authMiddleware - no API key', () => {
|
||||
it('should call next() when no API key is set', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authMiddleware - with API key', () => {
|
||||
it('should reject request without API key header', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
it('should reject request without any authentication', async () => {
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
|
||||
@@ -36,7 +25,7 @@ describe('auth.ts', () => {
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Authentication required. Provide X-API-Key header.',
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -70,46 +59,340 @@ describe('auth.ts', () => {
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should authenticate with session token in header', async () => {
|
||||
const { authMiddleware, createSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.headers['x-session-token'] = token;
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid session token in header', async () => {
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.headers['x-session-token'] = 'invalid-token';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid or expired session token.',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should authenticate with API key in query parameter', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.query.apiKey = 'test-secret-key';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should authenticate with session cookie', async () => {
|
||||
const { authMiddleware, createSession, getSessionCookieName } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
const cookieName = getSessionCookieName();
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.cookies = { [cookieName]: token };
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new session and return token', async () => {
|
||||
const { createSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create unique tokens for each session', async () => {
|
||||
const { createSession } = await import('@/lib/auth.js');
|
||||
const token1 = await createSession();
|
||||
const token2 = await createSession();
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSession', () => {
|
||||
it('should validate a valid session token', async () => {
|
||||
const { createSession, validateSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
expect(validateSession(token)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid session token', async () => {
|
||||
const { validateSession } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateSession('invalid-token')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject expired session token', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createSession, validateSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
// Advance time past session expiration (30 days)
|
||||
vi.advanceTimersByTime(31 * 24 * 60 * 60 * 1000);
|
||||
|
||||
expect(validateSession(token)).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateSession', () => {
|
||||
it('should invalidate a session token', async () => {
|
||||
const { createSession, validateSession, invalidateSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
expect(validateSession(token)).toBe(true);
|
||||
await invalidateSession(token);
|
||||
expect(validateSession(token)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWsConnectionToken', () => {
|
||||
it('should create a WebSocket connection token', async () => {
|
||||
const { createWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token = createWsConnectionToken();
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create unique tokens', async () => {
|
||||
const { createWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token1 = createWsConnectionToken();
|
||||
const token2 = createWsConnectionToken();
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWsConnectionToken', () => {
|
||||
it('should validate a valid WebSocket token', async () => {
|
||||
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token = createWsConnectionToken();
|
||||
|
||||
expect(validateWsConnectionToken(token)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid WebSocket token', async () => {
|
||||
const { validateWsConnectionToken } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateWsConnectionToken('invalid-token')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject expired WebSocket token', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token = createWsConnectionToken();
|
||||
|
||||
// Advance time past token expiration (5 minutes)
|
||||
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||
|
||||
expect(validateWsConnectionToken(token)).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should invalidate token after first use (single-use)', async () => {
|
||||
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token = createWsConnectionToken();
|
||||
|
||||
expect(validateWsConnectionToken(token)).toBe(true);
|
||||
// Token should be deleted after first use
|
||||
expect(validateWsConnectionToken(token)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey', () => {
|
||||
it('should validate correct API key', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateApiKey('test-secret-key')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject incorrect API key', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateApiKey('wrong-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty string', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateApiKey('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null/undefined', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateApiKey(null as any)).toBe(false);
|
||||
expect(validateApiKey(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('should use timing-safe comparison for different lengths', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
// Key with different length should be rejected without timing leak
|
||||
expect(validateApiKey('short')).toBe(false);
|
||||
expect(validateApiKey('very-long-key-that-does-not-match')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionCookieOptions', () => {
|
||||
it('should return cookie options with httpOnly true', async () => {
|
||||
const { getSessionCookieOptions } = await import('@/lib/auth.js');
|
||||
const options = getSessionCookieOptions();
|
||||
|
||||
expect(options.httpOnly).toBe(true);
|
||||
expect(options.sameSite).toBe('strict');
|
||||
expect(options.path).toBe('/');
|
||||
expect(options.maxAge).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should set secure to true in production', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const { getSessionCookieOptions } = await import('@/lib/auth.js');
|
||||
const options = getSessionCookieOptions();
|
||||
|
||||
expect(options.secure).toBe(true);
|
||||
});
|
||||
|
||||
it('should set secure to false in non-production', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const { getSessionCookieOptions } = await import('@/lib/auth.js');
|
||||
const options = getSessionCookieOptions();
|
||||
|
||||
expect(options.secure).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionCookieName', () => {
|
||||
it('should return the session cookie name', async () => {
|
||||
const { getSessionCookieName } = await import('@/lib/auth.js');
|
||||
const name = getSessionCookieName();
|
||||
|
||||
expect(name).toBe('automaker_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRequestAuthenticated', () => {
|
||||
it('should return true for authenticated request with API key', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { isRequestAuthenticated } = await import('@/lib/auth.js');
|
||||
const { req } = createMockExpressContext();
|
||||
req.headers['x-api-key'] = 'test-secret-key';
|
||||
|
||||
expect(isRequestAuthenticated(req)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unauthenticated request', async () => {
|
||||
const { isRequestAuthenticated } = await import('@/lib/auth.js');
|
||||
const { req } = createMockExpressContext();
|
||||
|
||||
expect(isRequestAuthenticated(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for authenticated request with session token', async () => {
|
||||
const { isRequestAuthenticated, createSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
const { req } = createMockExpressContext();
|
||||
req.headers['x-session-token'] = token;
|
||||
|
||||
expect(isRequestAuthenticated(req)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRawAuthentication', () => {
|
||||
it('should return true for valid API key in headers', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { checkRawAuthentication } = await import('@/lib/auth.js');
|
||||
|
||||
expect(checkRawAuthentication({ 'x-api-key': 'test-secret-key' }, {}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid session token in headers', async () => {
|
||||
const { checkRawAuthentication, createSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
expect(checkRawAuthentication({ 'x-session-token': token }, {}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid API key in query', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { checkRawAuthentication } = await import('@/lib/auth.js');
|
||||
|
||||
expect(checkRawAuthentication({}, { apiKey: 'test-secret-key' }, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid session cookie', async () => {
|
||||
const { checkRawAuthentication, createSession, getSessionCookieName } =
|
||||
await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
const cookieName = getSessionCookieName();
|
||||
|
||||
expect(checkRawAuthentication({}, {}, { [cookieName]: token })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid credentials', async () => {
|
||||
const { checkRawAuthentication } = await import('@/lib/auth.js');
|
||||
|
||||
expect(checkRawAuthentication({}, {}, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthEnabled', () => {
|
||||
it('should return false when no API key is set', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { isAuthEnabled } = await import('@/lib/auth.js');
|
||||
expect(isAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when API key is set', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-key';
|
||||
|
||||
it('should always return true (auth is always required)', async () => {
|
||||
const { isAuthEnabled } = await import('@/lib/auth.js');
|
||||
expect(isAuthEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthStatus', () => {
|
||||
it('should return disabled status when no API key', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { getAuthStatus } = await import('@/lib/auth.js');
|
||||
const status = getAuthStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: false,
|
||||
method: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return enabled status when API key is set', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-key';
|
||||
|
||||
it('should return enabled status with api_key_or_session method', async () => {
|
||||
const { getAuthStatus } = await import('@/lib/auth.js');
|
||||
const status = getAuthStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: true,
|
||||
method: 'api_key',
|
||||
method: 'api_key_or_session',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
378
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
378
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js';
|
||||
import type { SettingsService } from '@/services/settings-service.js';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual('@automaker/utils');
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
createLogger: () => mockLogger,
|
||||
};
|
||||
});
|
||||
|
||||
describe('settings-helpers.ts', () => {
|
||||
describe('getMCPServersFromSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty object when settingsService is null', async () => {
|
||||
const result = await getMCPServersFromSettings(null);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when settingsService is undefined', async () => {
|
||||
const result = await getMCPServersFromSettings(undefined);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when no MCP servers configured', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({ mcpServers: [] }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when mcpServers is undefined', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should convert enabled stdio server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'test-server',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { NODE_ENV: 'test' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'test-server': {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { NODE_ENV: 'test' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert enabled SSE server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'sse-server',
|
||||
type: 'sse',
|
||||
url: 'http://localhost:3000/sse',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'sse-server': {
|
||||
type: 'sse',
|
||||
url: 'http://localhost:3000/sse',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert enabled HTTP server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'http-server',
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/api',
|
||||
headers: { 'X-API-Key': 'secret' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'http-server': {
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/api',
|
||||
headers: { 'X-API-Key': 'secret' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out disabled servers', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'enabled-server',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'disabled-server',
|
||||
type: 'stdio',
|
||||
command: 'python',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(Object.keys(result)).toHaveLength(1);
|
||||
expect(result['enabled-server']).toBeDefined();
|
||||
expect(result['disabled-server']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should treat servers without enabled field as enabled', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'implicit-enabled',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
// enabled field not set
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result['implicit-enabled']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle multiple enabled servers', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{ id: '1', name: 'server1', type: 'stdio', command: 'node', enabled: true },
|
||||
{ id: '2', name: 'server2', type: 'stdio', command: 'python', enabled: true },
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result['server1']).toBeDefined();
|
||||
expect(result['server2']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty object and log error on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService, '[Test]');
|
||||
expect(result).toEqual({});
|
||||
// Logger will be called with error, but we don't need to assert it
|
||||
});
|
||||
|
||||
it('should throw error for SSE server without URL', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-sse',
|
||||
type: 'sse',
|
||||
enabled: true,
|
||||
// url missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// The error is caught and logged, returns empty
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error for HTTP server without URL', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-http',
|
||||
type: 'http',
|
||||
enabled: true,
|
||||
// url missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error for stdio server without command', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-stdio',
|
||||
type: 'stdio',
|
||||
enabled: true,
|
||||
// command missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should default to stdio type when type is not specified', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'no-type',
|
||||
command: 'node',
|
||||
enabled: true,
|
||||
// type not specified, should default to stdio
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result['no-type']).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: undefined,
|
||||
env: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMCPPermissionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is null', async () => {
|
||||
const result = await getMCPPermissionSettings(null);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is undefined', async () => {
|
||||
const result = await getMCPPermissionSettings(undefined);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return settings from service', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: false,
|
||||
mcpUnrestrictedTools: false,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: false,
|
||||
mcpUnrestrictedTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to true when settings are undefined', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed settings', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: false,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults and log error on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService, '[Test]');
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
// Logger will be called with error, but we don't need to assert it
|
||||
});
|
||||
|
||||
it('should use custom log prefix', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]');
|
||||
// Logger will be called with custom prefix, but we don't need to assert it
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -247,19 +247,15 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed');
|
||||
|
||||
// Should log error message
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'[ClaudeProvider] ERROR: executeQuery() error during execution:',
|
||||
testError
|
||||
);
|
||||
|
||||
// Should log stack trace
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'[ClaudeProvider] ERROR stack:',
|
||||
testError.stack
|
||||
);
|
||||
// Should log error with classification info (after refactoring)
|
||||
const errorCall = consoleErrorSpy.mock.calls[0];
|
||||
expect(errorCall[0]).toBe('[ClaudeProvider] executeQuery() error during execution:');
|
||||
expect(errorCall[1]).toMatchObject({
|
||||
type: expect.any(String),
|
||||
message: 'SDK execution failed',
|
||||
isRateLimit: false,
|
||||
stack: expect.stringContaining('Error: SDK execution failed'),
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
195
apps/server/tests/unit/routes/running-agents.test.ts
Normal file
195
apps/server/tests/unit/routes/running-agents.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createIndexHandler } from '@/routes/running-agents/routes/index.js';
|
||||
import type { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
|
||||
describe('running-agents routes', () => {
|
||||
let mockAutoModeService: Partial<AutoModeService>;
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockAutoModeService = {
|
||||
getRunningAgents: vi.fn(),
|
||||
};
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('GET / (index handler)', () => {
|
||||
it('should return empty array when no agents are running', async () => {
|
||||
// Arrange
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(mockAutoModeService.getRunningAgents).toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
runningAgents: [],
|
||||
totalCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return running agents with all properties', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/home/user/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
title: 'Implement login feature',
|
||||
description: 'Add user authentication with OAuth',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/other-project',
|
||||
projectName: 'other-project',
|
||||
isAutoMode: false,
|
||||
title: 'Fix navigation bug',
|
||||
description: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return agents without title/description (backward compatibility)', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'legacy-feature',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return 500', async () => {
|
||||
// Arrange
|
||||
const error = new Error('Database connection failed');
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue(error);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Database connection failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
// Arrange
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue('String error');
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly count multiple running agents', async () => {
|
||||
// Arrange
|
||||
const runningAgents = Array.from({ length: 10 }, (_, i) => ({
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
projectName: `project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
title: `Feature ${i}`,
|
||||
description: `Description ${i}`,
|
||||
}));
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include agents from different projects', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'feature-a',
|
||||
projectPath: '/workspace/project-alpha',
|
||||
projectName: 'project-alpha',
|
||||
isAutoMode: true,
|
||||
title: 'Feature A',
|
||||
description: 'In project alpha',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-b',
|
||||
projectPath: '/workspace/project-beta',
|
||||
projectName: 'project-beta',
|
||||
isAutoMode: false,
|
||||
title: 'Feature B',
|
||||
description: 'In project beta',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
const response = vi.mocked(res.json).mock.calls[0][0];
|
||||
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
|
||||
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,26 @@ import * as promptBuilder from '@automaker/utils';
|
||||
import * as contextLoader from '@automaker/utils';
|
||||
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||
|
||||
// Create a shared mock logger instance for assertions using vi.hoisted
|
||||
const mockLogger = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('@/providers/provider-factory.js');
|
||||
vi.mock('@automaker/utils');
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
loadContextFiles: vi.fn(),
|
||||
buildPromptWithImages: vi.fn(),
|
||||
readImageAsBase64: vi.fn(),
|
||||
createLogger: vi.fn(() => mockLogger),
|
||||
};
|
||||
});
|
||||
|
||||
describe('agent-service.ts', () => {
|
||||
let service: AgentService;
|
||||
@@ -224,16 +241,13 @@ describe('agent-service.ts', () => {
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: 'session-1',
|
||||
message: 'Check this',
|
||||
imagePaths: ['/path/test.png'],
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use custom model if provided', async () => {
|
||||
@@ -347,4 +361,386 @@ describe('agent-service.ts', () => {
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{}');
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should create a new session with metadata', async () => {
|
||||
const session = await service.createSession('Test Session', '/test/project', '/test/dir');
|
||||
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.name).toBe('Test Session');
|
||||
expect(session.projectPath).toBe('/test/project');
|
||||
expect(session.workingDirectory).toBeDefined();
|
||||
expect(session.createdAt).toBeDefined();
|
||||
expect(session.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use process.cwd() if no working directory provided', async () => {
|
||||
const session = await service.createSession('Test Session');
|
||||
|
||||
expect(session.workingDirectory).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate working directory', async () => {
|
||||
// Set ALLOWED_ROOT_DIRECTORY to restrict paths
|
||||
const originalAllowedRoot = process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/projects';
|
||||
|
||||
// Re-import platform to initialize with new env var
|
||||
vi.resetModules();
|
||||
const { initAllowedPaths } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const { AgentService } = await import('@/services/agent-service.js');
|
||||
const testService = new AgentService('/test/data', mockEvents as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{}');
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
testService.createSession('Test Session', undefined, '/invalid/path')
|
||||
).rejects.toThrow();
|
||||
|
||||
// Restore original value
|
||||
if (originalAllowedRoot) {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = originalAllowedRoot;
|
||||
} else {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
}
|
||||
vi.resetModules();
|
||||
const { initAllowedPaths: reinit } = await import('@automaker/platform');
|
||||
reinit();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSessionModel', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set model for existing session', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}');
|
||||
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should update session metadata', async () => {
|
||||
const result = await service.updateSession('session-1', { name: 'Updated Name' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Updated Name');
|
||||
expect(result?.updatedAt).not.toBe('2024-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('should return null for non-existent session', async () => {
|
||||
const result = await service.updateSession('nonexistent', { name: 'Updated Name' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should archive a session', async () => {
|
||||
const result = await service.archiveSession('session-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.archiveSession('nonexistent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchiveSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session',
|
||||
archived: true,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should unarchive a session', async () => {
|
||||
const result = await service.unarchiveSession('session-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.unarchiveSession('nonexistent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should delete a session', async () => {
|
||||
const result = await service.deleteSession('session-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.deleteSession('nonexistent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSessions', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session 1',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
archived: false,
|
||||
},
|
||||
'session-2': {
|
||||
id: 'session-2',
|
||||
name: 'Test Session 2',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-03T00:00:00Z',
|
||||
archived: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should list non-archived sessions by default', async () => {
|
||||
const sessions = await service.listSessions();
|
||||
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe('session-1');
|
||||
});
|
||||
|
||||
it('should include archived sessions when requested', async () => {
|
||||
const sessions = await service.listSessions(true);
|
||||
|
||||
expect(sessions.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should sort sessions by updatedAt descending', async () => {
|
||||
const sessions = await service.listSessions(true);
|
||||
|
||||
expect(sessions[0].id).toBe('session-2');
|
||||
expect(sessions[1].id).toBe('session-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToQueue', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add prompt to queue', async () => {
|
||||
const result = await service.addToQueue('session-1', {
|
||||
message: 'Test prompt',
|
||||
imagePaths: ['/test/image.png'],
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.queuedPrompt).toBeDefined();
|
||||
expect(result.queuedPrompt?.message).toBe('Test prompt');
|
||||
expect(mockEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', async () => {
|
||||
const result = await service.addToQueue('nonexistent', {
|
||||
message: 'Test prompt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Session not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueue', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return queue for session', async () => {
|
||||
await service.addToQueue('session-1', { message: 'Test prompt' });
|
||||
const result = service.getQueue('session-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.queue).toBeDefined();
|
||||
expect(result.queue?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', () => {
|
||||
const result = service.getQueue('nonexistent');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Session not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromQueue', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
const addResult = await service.addToQueue('session-1', { message: 'Test prompt' });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should remove prompt from queue', async () => {
|
||||
const queueResult = service.getQueue('session-1');
|
||||
const promptId = queueResult.queue![0].id;
|
||||
|
||||
const result = await service.removeFromQueue('session-1', promptId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', async () => {
|
||||
const result = await service.removeFromQueue('nonexistent', 'prompt-id');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Session not found');
|
||||
});
|
||||
|
||||
it('should return error for non-existent prompt', async () => {
|
||||
const result = await service.removeFromQueue('session-1', 'nonexistent-prompt-id');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Prompt not found in queue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearQueue', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
await service.addToQueue('session-1', { message: 'Test prompt 1' });
|
||||
await service.addToQueue('session-1', { message: 'Test prompt 2' });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should clear all prompts from queue', async () => {
|
||||
const result = await service.clearQueue('session-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const queueResult = service.getQueue('session-1');
|
||||
expect(queueResult.queue?.length).toBe(0);
|
||||
expect(mockEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', async () => {
|
||||
const result = await service.clearQueue('nonexistent');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Session not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,84 +24,87 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
it('should return empty string for skip mode', () => {
|
||||
it('should return empty string for skip mode', async () => {
|
||||
const feature = { id: 'test', planningMode: 'skip' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when planningMode is undefined', () => {
|
||||
it('should return empty string when planningMode is undefined', async () => {
|
||||
const feature = { id: 'test' };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return lite prompt for lite mode without approval', () => {
|
||||
it('should return lite prompt for lite mode without approval', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||
expect(result).toContain('[PLAN_GENERATED]');
|
||||
expect(result).toContain('Feature Request');
|
||||
});
|
||||
|
||||
it('should return lite_with_approval prompt for lite mode with approval', () => {
|
||||
it('should return lite_with_approval prompt for lite mode with approval', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: true,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Planning Phase (Lite Mode)');
|
||||
expect(result).toContain('[SPEC_GENERATED]');
|
||||
expect(result).toContain('DO NOT proceed with implementation');
|
||||
expect(result).toContain(
|
||||
'DO NOT proceed with implementation until you receive explicit approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return spec prompt for spec mode', () => {
|
||||
it('should return spec prompt for spec mode', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Specification Phase (Spec Mode)');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Specification Phase (Spec Mode)');
|
||||
expect(result).toContain('```tasks');
|
||||
expect(result).toContain('T001');
|
||||
expect(result).toContain('[TASK_START]');
|
||||
expect(result).toContain('[TASK_COMPLETE]');
|
||||
});
|
||||
|
||||
it('should return full prompt for full mode', () => {
|
||||
it('should return full prompt for full mode', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'full' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Full Specification Phase (Full SDD Mode)');
|
||||
expect(result).toContain('Phase 1: Foundation');
|
||||
expect(result).toContain('Phase 2: Core Implementation');
|
||||
expect(result).toContain('Phase 3: Integration & Testing');
|
||||
});
|
||||
|
||||
it('should include the separator and Feature Request header', () => {
|
||||
it('should include the separator and Feature Request header', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('## Feature Request');
|
||||
});
|
||||
|
||||
it('should instruct agent to NOT output exploration text', () => {
|
||||
it('should instruct agent to NOT output exploration text', async () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Do NOT output exploration text');
|
||||
expect(result).toContain('Start DIRECTLY');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
// All modes should have the IMPORTANT instruction about not outputting exploration text
|
||||
expect(result).toContain('IMPORTANT: Do NOT output exploration text');
|
||||
expect(result).toContain('Silently analyze the codebase first');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -279,18 +282,18 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
it('should have all required planning modes', () => {
|
||||
it('should have all required planning modes', async () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result.length).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('lite prompt should include correct structure', () => {
|
||||
it('lite prompt should include correct structure', async () => {
|
||||
const feature = { id: 'test', planningMode: 'lite' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Goal');
|
||||
expect(result).toContain('Approach');
|
||||
expect(result).toContain('Files to Touch');
|
||||
@@ -298,9 +301,9 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
expect(result).toContain('Risks');
|
||||
});
|
||||
|
||||
it('spec prompt should include task format instructions', () => {
|
||||
it('spec prompt should include task format instructions', async () => {
|
||||
const feature = { id: 'test', planningMode: 'spec' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Problem');
|
||||
expect(result).toContain('Solution');
|
||||
expect(result).toContain('Acceptance Criteria');
|
||||
@@ -309,13 +312,13 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
expect(result).toContain('Verification');
|
||||
});
|
||||
|
||||
it('full prompt should include phases', () => {
|
||||
it('full prompt should include phases', async () => {
|
||||
const feature = { id: 'test', planningMode: 'full' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Problem Statement');
|
||||
expect(result).toContain('User Story');
|
||||
expect(result).toContain('Technical Context');
|
||||
expect(result).toContain('Non-Goals');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('1. **Problem Statement**');
|
||||
expect(result).toContain('2. **User Story**');
|
||||
expect(result).toContain('4. **Technical Context**');
|
||||
expect(result).toContain('5. **Non-Goals**');
|
||||
expect(result).toContain('Phase 1');
|
||||
expect(result).toContain('Phase 2');
|
||||
expect(result).toContain('Phase 3');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('auto-mode-service.ts', () => {
|
||||
let service: AutoModeService;
|
||||
@@ -66,4 +67,252 @@ describe('auto-mode-service.ts', () => {
|
||||
expect(runningCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningAgents', () => {
|
||||
// Helper to access private runningFeatures Map
|
||||
const getRunningFeaturesMap = (svc: AutoModeService) =>
|
||||
(svc as any).runningFeatures as Map<
|
||||
string,
|
||||
{ featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
>;
|
||||
|
||||
// Helper to get the featureLoader and mock its get method
|
||||
const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).featureLoader = { get: mockFn };
|
||||
};
|
||||
|
||||
it('should return empty array when no agents are running', async () => {
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return running agents with basic info when feature data is not available', async () => {
|
||||
// Arrange: Add a running feature to the Map
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-123', {
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/test/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// Mock featureLoader.get to return null (feature not found)
|
||||
const getMock = vi.fn().mockResolvedValue(null);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/test/project/path',
|
||||
projectName: 'path',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return running agents with title and description when feature data is available', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-456', {
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/my-project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const mockFeature: Partial<Feature> = {
|
||||
id: 'feature-456',
|
||||
title: 'Implement user authentication',
|
||||
description: 'Add login and signup functionality',
|
||||
category: 'auth',
|
||||
};
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue(mockFeature);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/my-project',
|
||||
projectName: 'my-project',
|
||||
isAutoMode: false,
|
||||
title: 'Implement user authentication',
|
||||
description: 'Add login and signup functionality',
|
||||
});
|
||||
expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456');
|
||||
});
|
||||
|
||||
it('should handle multiple running agents', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'feature-1',
|
||||
title: 'Feature One',
|
||||
description: 'Description one',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'feature-2',
|
||||
title: 'Feature Two',
|
||||
description: 'Description two',
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(2);
|
||||
expect(getMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should silently handle errors when fetching feature data', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-error', {
|
||||
featureId: 'feature-error',
|
||||
projectPath: '/project-error',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed'));
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act - should not throw
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-error',
|
||||
projectPath: '/project-error',
|
||||
projectName: 'project-error',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle feature with title but no description', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-title-only', {
|
||||
featureId: 'feature-title-only',
|
||||
projectPath: '/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue({
|
||||
id: 'feature-title-only',
|
||||
title: 'Only Title',
|
||||
// description is undefined
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].title).toBe('Only Title');
|
||||
expect(result[0].description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle feature with description but no title', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-desc-only', {
|
||||
featureId: 'feature-desc-only',
|
||||
projectPath: '/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue({
|
||||
id: 'feature-desc-only',
|
||||
description: 'Only description, no title',
|
||||
// title is undefined
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].title).toBeUndefined();
|
||||
expect(result[0].description).toBe('Only description, no title');
|
||||
});
|
||||
|
||||
it('should extract projectName from nested paths correctly', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-nested', {
|
||||
featureId: 'feature-nested',
|
||||
projectPath: '/home/user/workspace/projects/my-awesome-project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue(null);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].projectName).toBe('my-awesome-project');
|
||||
});
|
||||
|
||||
it('should fetch feature data in parallel for multiple agents', async () => {
|
||||
// Arrange: Add multiple running features
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
runningFeaturesMap.set(`feature-${i}`, {
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Track call order
|
||||
const callOrder: string[] = [];
|
||||
const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => {
|
||||
callOrder.push(featureId);
|
||||
// Simulate async delay to verify parallel execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return { id: featureId, title: `Title for ${featureId}` };
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const startTime = Date.now();
|
||||
const result = await service.getRunningAgents();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(5);
|
||||
expect(getMock).toHaveBeenCalledTimes(5);
|
||||
// If executed in parallel, total time should be ~10ms (one batch)
|
||||
// If sequential, it would be ~50ms (5 * 10ms)
|
||||
// Allow some buffer for execution overhead
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -485,7 +485,7 @@ Resets in 2h
|
||||
await expect(promise).rejects.toThrow('Authentication required');
|
||||
});
|
||||
|
||||
it('should handle timeout', async () => {
|
||||
it('should handle timeout with no data', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockSpawnProcess.stdout = {
|
||||
@@ -619,7 +619,7 @@ Resets in 2h
|
||||
await expect(promise).rejects.toThrow('Authentication required');
|
||||
});
|
||||
|
||||
it('should handle timeout on Windows', async () => {
|
||||
it('should handle timeout with no data on Windows', async () => {
|
||||
vi.useFakeTimers();
|
||||
const windowsService = new ClaudeUsageService();
|
||||
|
||||
@@ -640,5 +640,69 @@ Resets in 2h
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return data on timeout if data was captured', async () => {
|
||||
vi.useFakeTimers();
|
||||
const windowsService = new ClaudeUsageService();
|
||||
|
||||
let dataCallback: Function | undefined;
|
||||
|
||||
const mockPty = {
|
||||
onData: vi.fn((callback: Function) => {
|
||||
dataCallback = callback;
|
||||
}),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||
|
||||
const promise = windowsService.fetchUsageData();
|
||||
|
||||
// Simulate receiving usage data
|
||||
dataCallback!('Current session\n65% left\nResets in 2h');
|
||||
|
||||
// Advance time past timeout (30 seconds)
|
||||
vi.advanceTimersByTime(31000);
|
||||
|
||||
// Should resolve with data instead of rejecting
|
||||
const result = await promise;
|
||||
expect(result.sessionPercentage).toBe(35); // 100 - 65
|
||||
expect(mockPty.kill).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should send SIGTERM after ESC if process does not exit', async () => {
|
||||
vi.useFakeTimers();
|
||||
const windowsService = new ClaudeUsageService();
|
||||
|
||||
let dataCallback: Function | undefined;
|
||||
|
||||
const mockPty = {
|
||||
onData: vi.fn((callback: Function) => {
|
||||
dataCallback = callback;
|
||||
}),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||
|
||||
windowsService.fetchUsageData();
|
||||
|
||||
// Simulate seeing usage data
|
||||
dataCallback!('Current session\n65% left');
|
||||
|
||||
// Advance 2s to trigger ESC
|
||||
vi.advanceTimersByTime(2100);
|
||||
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
||||
|
||||
// Advance another 2s to trigger SIGTERM fallback
|
||||
vi.advanceTimersByTime(2100);
|
||||
expect(mockPty.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TerminalService, getTerminalService } from '@/services/terminal-service.js';
|
||||
import * as pty from 'node-pty';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as platform from '@automaker/platform';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
|
||||
vi.mock('node-pty');
|
||||
vi.mock('fs');
|
||||
vi.mock('os');
|
||||
vi.mock('@automaker/platform', async () => {
|
||||
const actual = await vi.importActual('@automaker/platform');
|
||||
return {
|
||||
...actual,
|
||||
systemPathExists: vi.fn(),
|
||||
systemPathReadFileSync: vi.fn(),
|
||||
getWslVersionPath: vi.fn(),
|
||||
getShellPaths: vi.fn(), // Mock shell paths for cross-platform testing
|
||||
isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/secure-fs.js');
|
||||
|
||||
describe('terminal-service.ts', () => {
|
||||
let service: TerminalService;
|
||||
let mockPtyProcess: any;
|
||||
|
||||
// Shell paths for each platform (matching system-paths.ts)
|
||||
const linuxShellPaths = [
|
||||
'/bin/zsh',
|
||||
'/bin/bash',
|
||||
'/bin/sh',
|
||||
'/usr/bin/zsh',
|
||||
'/usr/bin/bash',
|
||||
'/usr/bin/sh',
|
||||
'/usr/local/bin/zsh',
|
||||
'/usr/local/bin/bash',
|
||||
'/opt/homebrew/bin/zsh',
|
||||
'/opt/homebrew/bin/bash',
|
||||
'zsh',
|
||||
'bash',
|
||||
'sh',
|
||||
];
|
||||
|
||||
const windowsShellPaths = [
|
||||
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe',
|
||||
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'C:\\Windows\\System32\\cmd.exe',
|
||||
'pwsh.exe',
|
||||
'pwsh',
|
||||
'powershell.exe',
|
||||
'powershell',
|
||||
'cmd.exe',
|
||||
'cmd',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new TerminalService();
|
||||
@@ -29,6 +71,13 @@ describe('terminal-service.ts', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.arch).mockReturnValue('x64');
|
||||
|
||||
// Default mocks for system paths and secureFs
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('');
|
||||
vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version');
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(linuxShellPaths); // Default to Linux paths
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -38,7 +87,8 @@ describe('terminal-service.ts', () => {
|
||||
describe('detectShell', () => {
|
||||
it('should detect PowerShell Core on Windows when available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
});
|
||||
|
||||
@@ -50,7 +100,8 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
it('should fall back to PowerShell on Windows if Core not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
});
|
||||
|
||||
@@ -62,7 +113,8 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
it('should fall back to cmd.exe on Windows if no PowerShell', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -73,7 +125,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect user shell on macOS', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -84,7 +136,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to zsh on macOS if user shell not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/zsh';
|
||||
});
|
||||
|
||||
@@ -97,7 +149,10 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to bash on macOS if zsh not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
// zsh not available, but bash is
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/bash';
|
||||
});
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -108,7 +163,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect user shell on Linux', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -119,7 +174,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to bash on Linux if user shell not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/bash';
|
||||
});
|
||||
|
||||
@@ -132,7 +187,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to sh on Linux if bash not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -143,8 +198,10 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect WSL and use appropriate shell', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
|
||||
'Linux version 5.10.0-microsoft-standard-WSL2'
|
||||
);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -155,43 +212,45 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
describe('isWSL', () => {
|
||||
it('should return true if /proc/version contains microsoft', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
|
||||
'Linux version 5.10.0-microsoft-standard-WSL2'
|
||||
);
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if /proc/version contains wsl', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if WSL_DISTRO_NAME is set', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' });
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if WSLENV is set', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' });
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if not in WSL', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
|
||||
expect(service.isWSL()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if error reading /proc/version', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
@@ -203,7 +262,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should return platform information', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.arch).mockReturnValue('x64');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const info = service.getPlatformInfo();
|
||||
@@ -216,20 +275,21 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new terminal session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should create a new terminal session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/test/dir',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
expect(session.id).toMatch(/^term-/);
|
||||
expect(session.cwd).toBe('/test/dir');
|
||||
expect(session.shell).toBe('/bin/bash');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.id).toMatch(/^term-/);
|
||||
expect(session!.cwd).toBe('/test/dir');
|
||||
expect(session!.shell).toBe('/bin/bash');
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
'/bin/bash',
|
||||
['--login'],
|
||||
@@ -241,12 +301,12 @@ describe('terminal-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default cols and rows if not provided', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should use default cols and rows if not provided', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -258,66 +318,68 @@ describe('terminal-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to home directory if cwd does not exist', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
it('should fall back to home directory if cwd does not exist', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/nonexistent',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/home/user');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should fall back to home directory if cwd is not a directory', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);
|
||||
it('should fall back to home directory if cwd is not a directory', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => false } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/file.txt',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/home/user');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should fix double slashes in path', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should fix double slashes in path', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '//test/dir',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/test/dir');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/test/dir');
|
||||
});
|
||||
|
||||
it('should preserve WSL UNC paths', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should preserve WSL UNC paths', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '//wsl$/Ubuntu/home',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('//wsl$/Ubuntu/home');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('//wsl$/Ubuntu/home');
|
||||
});
|
||||
|
||||
it('should handle data events from PTY', () => {
|
||||
it('should handle data events from PTY', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const dataCallback = vi.fn();
|
||||
service.onData(dataCallback);
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
// Simulate data event
|
||||
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
|
||||
@@ -331,33 +393,34 @@ describe('terminal-service.ts', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle exit events from PTY', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle exit events from PTY', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const exitCallback = vi.fn();
|
||||
service.onExit(exitCallback);
|
||||
|
||||
const session = service.createSession();
|
||||
const session = await service.createSession();
|
||||
|
||||
// Simulate exit event
|
||||
const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0];
|
||||
onExitHandler({ exitCode: 0 });
|
||||
|
||||
expect(exitCallback).toHaveBeenCalledWith(session.id, 0);
|
||||
expect(service.getSession(session.id)).toBeUndefined();
|
||||
expect(session).not.toBeNull();
|
||||
expect(exitCallback).toHaveBeenCalledWith(session!.id, 0);
|
||||
expect(service.getSession(session!.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('write', () => {
|
||||
it('should write data to existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should write data to existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.write(session.id, 'ls\n');
|
||||
const session = await service.createSession();
|
||||
const result = service.write(session!.id, 'ls\n');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n');
|
||||
@@ -372,13 +435,13 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('resize', () => {
|
||||
it('should resize existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should resize existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.resize(session.id, 120, 40);
|
||||
const session = await service.createSession();
|
||||
const result = service.resize(session!.id, 120, 40);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
|
||||
@@ -391,30 +454,30 @@ describe('terminal-service.ts', () => {
|
||||
expect(mockPtyProcess.resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle resize errors', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle resize errors', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.resize.mockImplementation(() => {
|
||||
throw new Error('Resize failed');
|
||||
});
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.resize(session.id, 120, 40);
|
||||
const session = await service.createSession();
|
||||
const result = service.resize(session!.id, 120, 40);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killSession', () => {
|
||||
it('should kill existing session', () => {
|
||||
it('should kill existing session', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.killSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const result = service.killSession(session!.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
@@ -423,7 +486,7 @@ describe('terminal-service.ts', () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(service.getSession(session.id)).toBeUndefined();
|
||||
expect(service.getSession(session!.id)).toBeUndefined();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -434,29 +497,29 @@ describe('terminal-service.ts', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle kill errors', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle kill errors', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.kill.mockImplementation(() => {
|
||||
throw new Error('Kill failed');
|
||||
});
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.killSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const result = service.killSession(session!.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
it('should return existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const retrieved = service.getSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const retrieved = service.getSession(session!.id);
|
||||
|
||||
expect(retrieved).toBe(session);
|
||||
});
|
||||
@@ -469,15 +532,15 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('getScrollback', () => {
|
||||
it('should return scrollback buffer for existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return scrollback buffer for existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
session.scrollbackBuffer = 'test scrollback';
|
||||
const session = await service.createSession();
|
||||
session!.scrollbackBuffer = 'test scrollback';
|
||||
|
||||
const scrollback = service.getScrollback(session.id);
|
||||
const scrollback = service.getScrollback(session!.id);
|
||||
|
||||
expect(scrollback).toBe('test scrollback');
|
||||
});
|
||||
@@ -490,19 +553,21 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('getAllSessions', () => {
|
||||
it('should return all active sessions', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return all active sessions', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session1 = service.createSession({ cwd: '/dir1' });
|
||||
const session2 = service.createSession({ cwd: '/dir2' });
|
||||
const session1 = await service.createSession({ cwd: '/dir1' });
|
||||
const session2 = await service.createSession({ cwd: '/dir2' });
|
||||
|
||||
const sessions = service.getAllSessions();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0].id).toBe(session1.id);
|
||||
expect(sessions[1].id).toBe(session2.id);
|
||||
expect(session1).not.toBeNull();
|
||||
expect(session2).not.toBeNull();
|
||||
expect(sessions[0].id).toBe(session1!.id);
|
||||
expect(sessions[1].id).toBe(session2!.id);
|
||||
expect(sessions[0].cwd).toBe('/dir1');
|
||||
expect(sessions[1].cwd).toBe('/dir2');
|
||||
});
|
||||
@@ -535,30 +600,32 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up all sessions', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should clean up all sessions', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session1 = service.createSession();
|
||||
const session2 = service.createSession();
|
||||
const session1 = await service.createSession();
|
||||
const session2 = await service.createSession();
|
||||
|
||||
service.cleanup();
|
||||
|
||||
expect(service.getSession(session1.id)).toBeUndefined();
|
||||
expect(service.getSession(session2.id)).toBeUndefined();
|
||||
expect(session1).not.toBeNull();
|
||||
expect(session2).not.toBeNull();
|
||||
expect(service.getSession(session1!.id)).toBeUndefined();
|
||||
expect(service.getSession(session2!.id)).toBeUndefined();
|
||||
expect(service.getAllSessions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle cleanup errors gracefully', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle cleanup errors gracefully', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.kill.mockImplementation(() => {
|
||||
throw new Error('Kill failed');
|
||||
});
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
expect(() => service.cleanup()).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Automaker UI
|
||||
# Multi-stage build for minimal production image
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY apps/ui/package*.json ./apps/ui/
|
||||
COPY scripts ./scripts
|
||||
|
||||
# Install dependencies (skip electron postinstall)
|
||||
RUN npm ci --workspace=apps/ui --ignore-scripts
|
||||
|
||||
# Copy source
|
||||
COPY apps/ui ./apps/ui
|
||||
|
||||
# Build for web (skip electron)
|
||||
# VITE_SERVER_URL tells the UI where to find the API server
|
||||
# Using localhost:3008 since both containers expose ports to the host
|
||||
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
|
||||
ARG VITE_SERVER_URL=http://localhost:3008
|
||||
ENV VITE_SKIP_ELECTRON=true
|
||||
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||
RUN npm run build --workspace=apps/ui
|
||||
|
||||
# Production stage - serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/apps/ui/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config for SPA routing
|
||||
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.2",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
@@ -10,6 +10,9 @@
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -27,95 +30,95 @@
|
||||
"build:electron:linux:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --linux --dir",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint",
|
||||
"pretest": "node scripts/setup-e2e-fixtures.mjs",
|
||||
"lint": "npx eslint",
|
||||
"pretest": "node scripts/kill-test-servers.mjs && node scripts/setup-e2e-fixtures.mjs",
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"dev:electron:wsl": "cross-env vite",
|
||||
"dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@automaker/dependency-resolver": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-router": "^1.141.6",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-router": "1.141.6",
|
||||
"@uiw/react-codemirror": "4.25.4",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/addon-search": "0.15.0",
|
||||
"@xterm/addon-web-links": "0.11.0",
|
||||
"@xterm/addon-webgl": "0.18.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"@xyflow/react": "12.10.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"dagre": "0.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
"geist": "1.5.1",
|
||||
"lucide-react": "0.562.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zustand": "^5.0.9"
|
||||
"react-markdown": "10.1.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"rehype-raw": "7.0.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "^1.29.2",
|
||||
"lightningcss-darwin-x64": "^1.29.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "^1.29.2",
|
||||
"lightningcss-linux-arm64-gnu": "^1.29.2",
|
||||
"lightningcss-linux-arm64-musl": "^1.29.2",
|
||||
"lightningcss-linux-x64-gnu": "^1.29.2",
|
||||
"lightningcss-linux-x64-musl": "^1.29.2",
|
||||
"lightningcss-win32-arm64-msvc": "^1.29.2",
|
||||
"lightningcss-win32-x64-msvc": "^1.29.2"
|
||||
"lightningcss-darwin-arm64": "1.29.2",
|
||||
"lightningcss-darwin-x64": "1.29.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.29.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.29.2",
|
||||
"lightningcss-linux-arm64-musl": "1.29.2",
|
||||
"lightningcss-linux-x64-gnu": "1.29.2",
|
||||
"lightningcss-linux-x64-musl": "1.29.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.29.2",
|
||||
"lightningcss-win32-x64-msvc": "1.29.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/router-plugin": "^1.141.7",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.50.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"@electron/rebuild": "4.0.2",
|
||||
"@eslint/js": "9.0.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@tanstack/router-plugin": "1.141.7",
|
||||
"@types/dagre": "0.7.53",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.0",
|
||||
"@typescript-eslint/parser": "8.50.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"cross-env": "10.1.0",
|
||||
"electron": "39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"electron-builder": "26.0.12",
|
||||
"eslint": "9.39.2",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
"vite": "7.3.0",
|
||||
"vite-plugin-electron": "0.29.0",
|
||||
"vite-plugin-electron-renderer": "0.14.6"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.automaker.app",
|
||||
|
||||
@@ -3,21 +3,24 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
const port = process.env.TEST_PORT || 3007;
|
||||
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
||||
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
|
||||
const mockAgent = process.env.CI === 'true' || process.env.AUTOMAKER_MOCK_AGENT === 'true';
|
||||
// Always use mock agent for tests (disables rate limiting, uses mock Claude responses)
|
||||
const mockAgent = true;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: undefined,
|
||||
retries: 0,
|
||||
workers: 1, // Run sequentially to avoid auth conflicts with shared server
|
||||
reporter: 'html',
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: 'on-first-retry',
|
||||
trace: 'on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
// Global setup - authenticate before each test
|
||||
globalSetup: require.resolve('./tests/global-setup.ts'),
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
@@ -29,17 +32,25 @@ export default defineConfig({
|
||||
: {
|
||||
webServer: [
|
||||
// Backend server - runs with mock agent enabled in CI
|
||||
// Uses dev:test (no file watching) to avoid port conflicts from server restarts
|
||||
{
|
||||
command: `cd ../server && npm run dev`,
|
||||
command: `cd ../server && npm run dev:test`,
|
||||
url: `http://localhost:${serverPort}/api/health`,
|
||||
reuseExistingServer: true,
|
||||
// Don't reuse existing server to ensure we use the test API key
|
||||
reuseExistingServer: false,
|
||||
timeout: 60000,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(serverPort),
|
||||
// Enable mock agent in CI to avoid real API calls
|
||||
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
|
||||
// Set a test API key for web mode authentication
|
||||
AUTOMAKER_API_KEY: process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests',
|
||||
// Hide the API key banner to reduce log noise
|
||||
AUTOMAKER_HIDE_API_KEY: 'true',
|
||||
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
|
||||
// Simulate containerized environment to skip sandbox confirmation dialogs
|
||||
IS_CONTAINERIZED: 'true',
|
||||
},
|
||||
},
|
||||
// Frontend Vite dev server
|
||||
@@ -51,8 +62,8 @@ export default defineConfig({
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_SKIP_SETUP: 'true',
|
||||
// Skip electron plugin in CI - no display available for Electron
|
||||
VITE_SKIP_ELECTRON: process.env.CI === 'true' ? 'true' : undefined,
|
||||
// Always skip electron plugin during tests - prevents duplicate server spawning
|
||||
VITE_SKIP_ELECTRON: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
38
apps/ui/public/readme_logo.svg
Normal file
38
apps/ui/public/readme_logo.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1456" height="330" viewBox="0 0 1456 330" role="img" aria-label="Automaker Logo">
|
||||
<defs>
|
||||
<!-- Brand Gradient -->
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6B5BFF" />
|
||||
<stop offset="100%" stop-color="#2EC7FF" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Shadow filter -->
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Optional subtle background to ensure contrast on all GitHub themes -->
|
||||
<rect width="1456" height="330" rx="20" fill="#09090b" />
|
||||
|
||||
<!-- Logo Icon (Left Side) -->
|
||||
<g transform="translate(80, 65)">
|
||||
<!-- Rounded square background -->
|
||||
<rect width="200" height="200" rx="50" fill="url(#bg)" />
|
||||
|
||||
<!-- Icon paths -->
|
||||
<g fill="none" stroke="#FFFFFF" stroke-width="18" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)" transform="translate(100, 100)">
|
||||
<!-- Left bracket < -->
|
||||
<path d="M-30 -30 L-55 0 L-30 30" />
|
||||
<!-- Slash / -->
|
||||
<path d="M15 -45 L-15 45" />
|
||||
<!-- Right bracket > -->
|
||||
<path d="M30 -30 L55 0 L30 30" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Text Section -->
|
||||
<text x="320" y="215" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="160" font-weight="800" letter-spacing="-4" fill="#FFFFFF">
|
||||
automaker<tspan fill="#6B5BFF">.</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
92
apps/ui/scripts/bump-version.mjs
Executable file
92
apps/ui/scripts/bump-version.mjs
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bumps the version in apps/ui/package.json and apps/server/package.json
|
||||
* Usage: node scripts/bump-version.mjs [major|minor|patch]
|
||||
* Example: node scripts/bump-version.mjs patch
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const bumpType = process.argv[2]?.toLowerCase();
|
||||
|
||||
if (!bumpType || !['major', 'minor', 'patch'].includes(bumpType)) {
|
||||
console.error('Error: Bump type argument is required');
|
||||
console.error('Usage: node scripts/bump-version.mjs [major|minor|patch]');
|
||||
console.error('Example: node scripts/bump-version.mjs patch');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const uiPackageJsonPath = join(__dirname, '..', 'package.json');
|
||||
const serverPackageJsonPath = join(__dirname, '..', '..', 'server', 'package.json');
|
||||
|
||||
function bumpVersion(packageJsonPath, packageName) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
const oldVersion = packageJson.version;
|
||||
|
||||
// Parse version
|
||||
const versionParts = oldVersion.split('.').map(Number);
|
||||
if (versionParts.length !== 3) {
|
||||
console.error(`Error: Invalid version format in ${packageName}: ${oldVersion}`);
|
||||
console.error('Expected format: X.Y.Z (e.g., 1.2.3)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Bump version
|
||||
let [major, minor, patch] = versionParts;
|
||||
|
||||
switch (bumpType) {
|
||||
case 'major':
|
||||
major += 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
break;
|
||||
case 'minor':
|
||||
minor += 1;
|
||||
patch = 0;
|
||||
break;
|
||||
case 'patch':
|
||||
patch += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const newVersion = `${major}.${minor}.${patch}`;
|
||||
packageJson.version = newVersion;
|
||||
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
|
||||
|
||||
return newVersion;
|
||||
} catch (error) {
|
||||
console.error(`Error bumping version in ${packageName}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Bump UI package version
|
||||
const uiOldVersion = JSON.parse(readFileSync(uiPackageJsonPath, 'utf8')).version;
|
||||
const uiNewVersion = bumpVersion(uiPackageJsonPath, '@automaker/ui');
|
||||
|
||||
// Bump server package version (sync with UI)
|
||||
const serverOldVersion = JSON.parse(readFileSync(serverPackageJsonPath, 'utf8')).version;
|
||||
const serverNewVersion = bumpVersion(serverPackageJsonPath, '@automaker/server');
|
||||
|
||||
// Verify versions match
|
||||
if (uiNewVersion !== serverNewVersion) {
|
||||
console.error(`Error: Version mismatch! UI: ${uiNewVersion}, Server: ${serverNewVersion}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ Bumped version from ${uiOldVersion} to ${uiNewVersion} (${bumpType})`);
|
||||
console.log(`📦 Updated @automaker/ui: ${uiOldVersion} -> ${uiNewVersion}`);
|
||||
console.log(`📦 Updated @automaker/server: ${serverOldVersion} -> ${serverNewVersion}`);
|
||||
console.log(`📦 Version is now: ${uiNewVersion}`);
|
||||
} catch (error) {
|
||||
console.error(`Error bumping version: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
44
apps/ui/scripts/kill-test-servers.mjs
Normal file
44
apps/ui/scripts/kill-test-servers.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Kill any existing servers on test ports before running tests
|
||||
* This ensures the test server starts fresh with the correct API key
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const SERVER_PORT = process.env.TEST_SERVER_PORT || 3008;
|
||||
const UI_PORT = process.env.TEST_PORT || 3007;
|
||||
|
||||
async function killProcessOnPort(port) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`lsof -ti:${port}`);
|
||||
const pids = stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
if (pids.length > 0) {
|
||||
console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
await execAsync(`kill -9 ${pid}`);
|
||||
console.log(`[KillTestServers] Killed process ${pid}`);
|
||||
} catch (error) {
|
||||
// Process might have already exited
|
||||
}
|
||||
}
|
||||
// Wait a moment for the port to be released
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
} catch (error) {
|
||||
// No process on port, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[KillTestServers] Checking for existing test servers...');
|
||||
await killProcessOnPort(Number(SERVER_PORT));
|
||||
await killProcessOnPort(Number(UI_PORT));
|
||||
console.log('[KillTestServers] Done');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -5,6 +5,7 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -25,10 +26,15 @@ const REFRESH_INTERVAL_SECONDS = 45;
|
||||
|
||||
export function ClaudeUsagePopover() {
|
||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<UsageError | null>(null);
|
||||
|
||||
// Check if CLI is verified/authenticated
|
||||
const isCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
|
||||
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
|
||||
const isStale = useMemo(() => {
|
||||
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||
@@ -68,14 +74,17 @@ export function ClaudeUsagePopover() {
|
||||
[setClaudeUsage]
|
||||
);
|
||||
|
||||
// Auto-fetch on mount if data is stale
|
||||
// Auto-fetch on mount if data is stale (only if CLI is verified)
|
||||
useEffect(() => {
|
||||
if (isStale) {
|
||||
if (isStale && isCliVerified) {
|
||||
fetchUsage(true);
|
||||
}
|
||||
}, [isStale, fetchUsage]);
|
||||
}, [isStale, isCliVerified, fetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if CLI is not verified
|
||||
if (!isCliVerified) return;
|
||||
|
||||
// Initial fetch when opened
|
||||
if (open) {
|
||||
if (!claudeUsage || isStale) {
|
||||
@@ -94,7 +103,7 @@ export function ClaudeUsagePopover() {
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [open, claudeUsage, isStale, fetchUsage]);
|
||||
}, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -62,7 +62,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
// Update preview image when background settings change
|
||||
useEffect(() => {
|
||||
if (currentProject && backgroundSettings.imagePath) {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
// Add cache-busting query parameter to force browser to reload image
|
||||
const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`;
|
||||
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
||||
import { getJSON, setJSON } from '@/lib/storage';
|
||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||
import { useOSDetection } from '@/hooks';
|
||||
import { apiPost } from '@/lib/api-fetch';
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
@@ -98,16 +99,7 @@ export function FileBrowserDialog({
|
||||
setWarning('');
|
||||
|
||||
try {
|
||||
// Get server URL from environment or default
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dirPath }),
|
||||
});
|
||||
|
||||
const result: BrowseResult = await response.json();
|
||||
const result = await apiPost<BrowseResult>('/api/fs/browse', { dirPath });
|
||||
|
||||
if (result.success) {
|
||||
setCurrentPath(result.currentPath);
|
||||
|
||||
@@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-
|
||||
export { DeleteSessionDialog } from './delete-session-dialog';
|
||||
export { FileBrowserDialog } from './file-browser-dialog';
|
||||
export { NewProjectModal } from './new-project-modal';
|
||||
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
||||
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
||||
export { WorkspacePickerModal } from './workspace-picker-modal';
|
||||
|
||||
90
apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx
Normal file
90
apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Sandbox Rejection Screen
|
||||
*
|
||||
* Shown in web mode when user denies the sandbox risk confirmation.
|
||||
* Prompts them to either restart the app in a container or reload to try again.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRejectionScreen() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleReload = () => {
|
||||
// Clear the rejection state and reload
|
||||
sessionStorage.removeItem('automaker-sandbox-denied');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-destructive/10 p-4">
|
||||
<ShieldX className="w-12 h-12 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Access Denied</h1>
|
||||
<p className="text-muted-foreground">
|
||||
You declined to accept the risks of running Automaker outside a sandbox environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 text-left space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Container className="w-5 h-5 mt-0.5 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-sm">Run in Docker (Recommended)</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run Automaker in a containerized sandbox environment:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReload}
|
||||
className="gap-2"
|
||||
data-testid="sandbox-retry"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload & Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Normal file
112
apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Sandbox Risk Confirmation Dialog
|
||||
*
|
||||
* Shows when the app is running outside a containerized environment.
|
||||
* Users must acknowledge the risks before proceeding.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert, Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SandboxRiskDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="bg-popover border-border max-w-lg"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
Sandbox Environment Not Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="text-muted-foreground">
|
||||
<strong>Warning:</strong> This application is running outside of a containerized
|
||||
sandbox environment. AI agents will have direct access to your filesystem and can
|
||||
execute commands on your system.
|
||||
</p>
|
||||
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">Potential Risks:</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>Agents can read, modify, or delete files on your system</li>
|
||||
<li>Agents can execute arbitrary commands and install software</li>
|
||||
<li>Agents can access environment variables and credentials</li>
|
||||
<li>Unintended side effects from agent actions may affect your system</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For safer operation, consider running Automaker in Docker:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-muted/50 border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
||||
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
|
||||
Deny & Exit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="px-4"
|
||||
data-testid="sandbox-confirm"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ interface AutomakerLogoProps {
|
||||
}
|
||||
|
||||
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -17,7 +19,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{!sidebarOpen ? (
|
||||
<div className="relative flex items-center justify-center rounded-lg">
|
||||
<div className="relative flex flex-col items-center justify-center rounded-lg gap-0.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
@@ -61,54 +63,62 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex items-center gap-1', 'hidden lg:flex')}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
<div className={cn('flex flex-col', 'hidden lg:flex')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
|
||||
import {
|
||||
sanitizeFilename,
|
||||
@@ -93,7 +94,7 @@ export function DescriptionImageDropZone({
|
||||
// Construct server URL for loading saved images
|
||||
const getImageServerUrl = useCallback(
|
||||
(imagePath: string): string => {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
const projectPath = currentProject?.path || '';
|
||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||
},
|
||||
|
||||
@@ -206,6 +206,7 @@ export function BoardView() {
|
||||
checkContextExists,
|
||||
features: hookFeatures,
|
||||
isLoading,
|
||||
featuresWithContext,
|
||||
setFeaturesWithContext,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react';
|
||||
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
@@ -34,12 +35,18 @@ export function BoardHeader({
|
||||
isMounted,
|
||||
}: BoardHeaderProps) {
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
// Only show if CLI has been verified/authenticated
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const isCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
|
||||
@@ -143,7 +143,7 @@ export function CardActions({
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Verify
|
||||
</Button>
|
||||
) : hasContext && onResume ? (
|
||||
) : onResume ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -158,21 +158,6 @@ export function CardActions({
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : onVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`verify-feature-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : null}
|
||||
{onViewOutput && !feature.skipTests && (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import React, { memo, useLayoutEffect, useState } from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
@@ -10,6 +9,25 @@ import { CardContentSections } from './card-content-sections';
|
||||
import { AgentInfoPanel } from './agent-info-panel';
|
||||
import { CardActions } from './card-actions';
|
||||
|
||||
function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSProperties {
|
||||
if (!enabled) {
|
||||
return { borderWidth: '0px', borderColor: 'transparent' };
|
||||
}
|
||||
if (opacity !== 100) {
|
||||
return {
|
||||
borderWidth: '1px',
|
||||
borderColor: `color-mix(in oklch, var(--border) ${opacity}%, transparent)`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
|
||||
if (isOverlay) return 'cursor-grabbing';
|
||||
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
|
||||
return 'cursor-default';
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
onEdit: () => void;
|
||||
@@ -35,6 +53,7 @@ interface KanbanCardProps {
|
||||
glassmorphism?: boolean;
|
||||
cardBorderEnabled?: boolean;
|
||||
cardBorderOpacity?: number;
|
||||
isOverlay?: boolean;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
@@ -62,64 +81,63 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
glassmorphism = true,
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
isOverlay,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees } = useAppStore();
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isOverlay) {
|
||||
requestAnimationFrame(() => {
|
||||
setIsLifted(true);
|
||||
});
|
||||
}
|
||||
}, [isOverlay]);
|
||||
|
||||
const isDraggable =
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable,
|
||||
disabled: !isDraggable || isOverlay,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
|
||||
const borderStyle: React.CSSProperties = { ...style };
|
||||
if (!cardBorderEnabled) {
|
||||
(borderStyle as Record<string, string>).borderWidth = '0px';
|
||||
(borderStyle as Record<string, string>).borderColor = 'transparent';
|
||||
} else if (cardBorderOpacity !== 100) {
|
||||
(borderStyle as Record<string, string>).borderWidth = '1px';
|
||||
(borderStyle as Record<string, string>).borderColor =
|
||||
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
|
||||
}
|
||||
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
|
||||
|
||||
const cardElement = (
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
getCursorClass(isOverlay, isDraggable),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
|
||||
);
|
||||
|
||||
const isInteractive = !isDragging && !isOverlay;
|
||||
const hasError = feature.error && !isCurrentAutoTask;
|
||||
|
||||
const innerCardClasses = cn(
|
||||
'kanban-card-content h-full relative shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isCurrentAutoTask &&
|
||||
cardBorderEnabled &&
|
||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
|
||||
);
|
||||
|
||||
const renderCardContent = () => (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={isCurrentAutoTask ? style : borderStyle}
|
||||
className={cn(
|
||||
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Premium shadow system
|
||||
'shadow-sm hover:shadow-md hover:shadow-black/10',
|
||||
// Subtle lift on hover
|
||||
'hover:-translate-y-0.5',
|
||||
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
|
||||
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
|
||||
!isDragging && 'bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
|
||||
// Error state - using CSS variable
|
||||
feature.error &&
|
||||
!isCurrentAutoTask &&
|
||||
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
!isDraggable && 'cursor-default'
|
||||
)}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
style={isCurrentAutoTask ? undefined : cardStyle}
|
||||
className={innerCardClasses}
|
||||
onDoubleClick={onEdit}
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
>
|
||||
{/* Background overlay with opacity */}
|
||||
{!isDragging && (
|
||||
{(!isDragging || isOverlay) && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-xl bg-card -z-10',
|
||||
@@ -185,10 +203,20 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Wrap with animated border when in progress
|
||||
if (isCurrentAutoTask) {
|
||||
return <div className="animated-border-wrapper">{cardElement}</div>;
|
||||
}
|
||||
|
||||
return cardElement;
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={dndStyle}
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
className={wrapperClasses}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
>
|
||||
{isCurrentAutoTask ? (
|
||||
<div className="animated-border-wrapper">{renderCardContent()}</div>
|
||||
) : (
|
||||
renderCardContent()
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ interface AgentOutputModalProps {
|
||||
featureStatus?: string;
|
||||
/** Called when a number key (0-9) is pressed while the modal is open */
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'parsed' | 'raw' | 'changes';
|
||||
@@ -34,6 +36,7 @@ export function AgentOutputModal({
|
||||
featureId,
|
||||
featureStatus,
|
||||
onNumberKeyPress,
|
||||
projectPath: projectPathProp,
|
||||
}: AgentOutputModalProps) {
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -62,19 +65,19 @@ export function AgentOutputModal({
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Get current project path from store (we'll need to pass this)
|
||||
const currentProject = (window as any).__currentProject;
|
||||
if (!currentProject?.path) {
|
||||
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
|
||||
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
|
||||
if (!resolvedProjectPath) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectPathRef.current = currentProject.path;
|
||||
setProjectPath(currentProject.path);
|
||||
projectPathRef.current = resolvedProjectPath;
|
||||
setProjectPath(resolvedProjectPath);
|
||||
|
||||
// Use features API to get agent output
|
||||
if (api.features) {
|
||||
const result = await api.features.getAgentOutput(currentProject.path, featureId);
|
||||
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
|
||||
|
||||
if (result.success) {
|
||||
setOutput(result.content || '');
|
||||
@@ -93,7 +96,7 @@ export function AgentOutputModal({
|
||||
};
|
||||
|
||||
loadOutput();
|
||||
}, [open, featureId]);
|
||||
}, [open, featureId, projectPathProp]);
|
||||
|
||||
// Listen to auto mode events and update output
|
||||
useEffect(() => {
|
||||
@@ -102,9 +105,21 @@ export function AgentOutputModal({
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
console.log(
|
||||
'[AgentOutputModal] Received event:',
|
||||
event.type,
|
||||
'featureId:',
|
||||
'featureId' in event ? event.featureId : 'none',
|
||||
'modalFeatureId:',
|
||||
featureId
|
||||
);
|
||||
|
||||
// Filter events for this specific feature only (skip events without featureId)
|
||||
if ('featureId' in event && event.featureId !== featureId) {
|
||||
console.log('[AgentOutputModal] Skipping event - featureId mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -435,21 +435,33 @@ export function useBoardActions({
|
||||
|
||||
const handleResumeFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
console.log('[Board] handleResumeFeature called for feature:', feature.id);
|
||||
if (!currentProject) {
|
||||
console.error('[Board] No current project');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error('Auto mode API not available');
|
||||
console.error('[Board] Auto mode API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Board] Calling resumeFeature API...', {
|
||||
projectPath: currentProject.path,
|
||||
featureId: feature.id,
|
||||
useWorktrees,
|
||||
});
|
||||
|
||||
const result = await api.autoMode.resumeFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees
|
||||
);
|
||||
|
||||
console.log('[Board] resumeFeature result:', result);
|
||||
|
||||
if (result.success) {
|
||||
console.log('[Board] Feature resume started successfully');
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
|
||||
interface UseBoardBackgroundProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -23,7 +24,7 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${
|
||||
import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'
|
||||
import.meta.env.VITE_SERVER_URL || getServerUrlSync()
|
||||
}/api/fs/image?path=${encodeURIComponent(
|
||||
backgroundSettings.imagePath
|
||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UseBoardEffectsProps {
|
||||
checkContextExists: (featureId: string) => Promise<boolean>;
|
||||
features: any[];
|
||||
isLoading: boolean;
|
||||
featuresWithContext: Set<string>;
|
||||
setFeaturesWithContext: (set: Set<string>) => void;
|
||||
}
|
||||
|
||||
@@ -25,8 +26,14 @@ export function useBoardEffects({
|
||||
checkContextExists,
|
||||
features,
|
||||
isLoading,
|
||||
featuresWithContext,
|
||||
setFeaturesWithContext,
|
||||
}: UseBoardEffectsProps) {
|
||||
// Keep a ref to the current featuresWithContext for use in event handlers
|
||||
const featuresWithContextRef = useRef(featuresWithContext);
|
||||
useEffect(() => {
|
||||
featuresWithContextRef.current = featuresWithContext;
|
||||
}, [featuresWithContext]);
|
||||
// Make current project available globally for modal
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
@@ -146,4 +153,30 @@ export function useBoardEffects({
|
||||
checkAllContexts();
|
||||
}
|
||||
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
|
||||
|
||||
// Re-check context when a feature stops, completes, or errors
|
||||
// This ensures hasContext is updated even if the features array doesn't change
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent(async (event) => {
|
||||
// When a feature stops (error/abort) or completes, re-check its context
|
||||
if (
|
||||
(event.type === 'auto_mode_error' || event.type === 'auto_mode_feature_complete') &&
|
||||
event.featureId
|
||||
) {
|
||||
const hasContext = await checkContextExists(event.featureId);
|
||||
if (hasContext) {
|
||||
const newSet = new Set(featuresWithContextRef.current);
|
||||
newSet.add(event.featureId);
|
||||
setFeaturesWithContext(newSet);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [checkContextExists, setFeaturesWithContext]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { KanbanColumn, KanbanCard } from './components';
|
||||
@@ -241,19 +240,32 @@ export function KanbanBoard({
|
||||
}}
|
||||
>
|
||||
{activeFeature && (
|
||||
<Card
|
||||
className="rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform"
|
||||
style={{ width: `${columnWidth}px` }}
|
||||
>
|
||||
<CardHeader className="p-3">
|
||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||
{activeFeature.description}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
{activeFeature.category}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div style={{ width: `${columnWidth}px` }}>
|
||||
<KanbanCard
|
||||
feature={activeFeature}
|
||||
isOverlay
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onViewOutput={() => {}}
|
||||
onVerify={() => {}}
|
||||
onResume={() => {}}
|
||||
onForceStop={() => {}}
|
||||
onManualVerify={() => {}}
|
||||
onMoveBackToInProgress={() => {}}
|
||||
onFollowUp={() => {}}
|
||||
onImplement={() => {}}
|
||||
onComplete={() => {}}
|
||||
onViewPlan={() => {}}
|
||||
onApprovePlan={() => {}}
|
||||
onSpawnTask={() => {}}
|
||||
hasContext={featuresWithContext.has(activeFeature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
@@ -178,6 +178,13 @@ export function ContextView() {
|
||||
// Ensure context directory exists
|
||||
await api.mkdir(contextPath);
|
||||
|
||||
// Ensure metadata file exists (create empty one if not)
|
||||
const metadataPath = `${contextPath}/context-metadata.json`;
|
||||
const metadataExists = await api.exists(metadataPath);
|
||||
if (!metadataExists) {
|
||||
await api.writeFile(metadataPath, JSON.stringify({ files: {} }, null, 2));
|
||||
}
|
||||
|
||||
// Load metadata for descriptions
|
||||
const metadata = await loadMetadata();
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@ import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'
|
||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||
import type { ValidateIssueOptions } from './github-issues-view/types';
|
||||
|
||||
export function GitHubIssuesView() {
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
|
||||
const [showValidationDialog, setShowValidationDialog] = useState(false);
|
||||
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
|
||||
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
||||
useState<ValidateIssueOptions | null>(null);
|
||||
|
||||
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
|
||||
useAppStore();
|
||||
@@ -203,7 +206,10 @@ export function GitHubIssuesView() {
|
||||
onViewCachedValidation={handleViewCachedValidation}
|
||||
onOpenInGitHub={handleOpenInGitHub}
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
|
||||
onShowRevalidateConfirm={(options) => {
|
||||
setPendingRevalidateOptions(options);
|
||||
setShowRevalidateConfirm(true);
|
||||
}}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
)}
|
||||
@@ -220,15 +226,24 @@ export function GitHubIssuesView() {
|
||||
{/* Revalidate Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={showRevalidateConfirm}
|
||||
onOpenChange={setShowRevalidateConfirm}
|
||||
onOpenChange={(open) => {
|
||||
setShowRevalidateConfirm(open);
|
||||
if (!open) {
|
||||
setPendingRevalidateOptions(null);
|
||||
}
|
||||
}}
|
||||
title="Re-validate Issue"
|
||||
description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`}
|
||||
icon={RefreshCw}
|
||||
iconClassName="text-primary"
|
||||
confirmText="Re-validate"
|
||||
onConfirm={() => {
|
||||
if (selectedIssue) {
|
||||
handleValidateIssue(selectedIssue, { forceRevalidate: true });
|
||||
if (selectedIssue && pendingRevalidateOptions) {
|
||||
console.log('[GitHubIssuesView] Revalidating with options:', {
|
||||
commentsCount: pendingRevalidateOptions.comments?.length ?? 0,
|
||||
linkedPRsCount: pendingRevalidateOptions.linkedPRs?.length ?? 0,
|
||||
});
|
||||
handleValidateIssue(selectedIssue, pendingRevalidateOptions);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { User } from 'lucide-react';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import type { GitHubComment } from '@/lib/electron';
|
||||
import { formatDate } from '../utils';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: GitHubComment;
|
||||
}
|
||||
|
||||
export function CommentItem({ comment }: CommentItemProps) {
|
||||
return (
|
||||
<div className="p-3 rounded-lg bg-background border border-border">
|
||||
{/* Comment Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{comment.author.avatarUrl ? (
|
||||
<img
|
||||
src={comment.author.avatarUrl}
|
||||
alt={comment.author.login}
|
||||
className="h-6 w-6 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium">{comment.author.login}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
commented {formatDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comment Body */}
|
||||
{comment.body ? (
|
||||
<Markdown className="text-sm">{comment.body}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No content</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { IssueRow } from './issue-row';
|
||||
export { IssueDetailPanel } from './issue-detail-panel';
|
||||
export { IssuesListHeader } from './issues-list-header';
|
||||
export { CommentItem } from './comment-item';
|
||||
|
||||
@@ -10,12 +10,19 @@ import {
|
||||
GitPullRequest,
|
||||
User,
|
||||
RefreshCw,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueDetailPanelProps } from '../types';
|
||||
import { isValidationStale } from '../utils';
|
||||
import { useIssueComments } from '../hooks';
|
||||
import { CommentItem } from './comment-item';
|
||||
|
||||
export function IssueDetailPanel({
|
||||
issue,
|
||||
@@ -32,6 +39,32 @@ export function IssueDetailPanel({
|
||||
const cached = cachedValidations.get(issue.number);
|
||||
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
|
||||
|
||||
// Comments state
|
||||
const [commentsExpanded, setCommentsExpanded] = useState(true);
|
||||
const [includeCommentsInAnalysis, setIncludeCommentsInAnalysis] = useState(true);
|
||||
const {
|
||||
comments,
|
||||
totalCount,
|
||||
loading: commentsLoading,
|
||||
loadingMore,
|
||||
hasNextPage,
|
||||
error: commentsError,
|
||||
loadMore,
|
||||
} = useIssueComments(issue.number);
|
||||
|
||||
// Helper to get validation options with comments and linked PRs
|
||||
const getValidationOptions = (forceRevalidate = false) => {
|
||||
return {
|
||||
forceRevalidate,
|
||||
comments: includeCommentsInAnalysis && comments.length > 0 ? comments : undefined,
|
||||
linkedPRs: issue.linkedPRs?.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.state,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
@@ -67,7 +100,7 @@ export function IssueDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onShowRevalidateConfirm}
|
||||
onClick={() => onShowRevalidateConfirm(getValidationOptions(true))}
|
||||
title="Re-validate"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
@@ -86,7 +119,7 @@ export function IssueDetailPanel({
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onValidateIssue(issue, { forceRevalidate: true })}
|
||||
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 mr-1" />
|
||||
Re-validate
|
||||
@@ -96,7 +129,11 @@ export function IssueDetailPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="default" size="sm" onClick={() => onValidateIssue(issue)}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onValidateIssue(issue, getValidationOptions())}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 mr-1" />
|
||||
Validate with AI
|
||||
</Button>
|
||||
@@ -226,6 +263,74 @@ export function IssueDetailPanel({
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Comments Section */}
|
||||
<div className="mt-6 p-3 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="flex items-center gap-2 text-left"
|
||||
onClick={() => setCommentsExpanded(!commentsExpanded)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">
|
||||
Comments {totalCount > 0 && `(${totalCount})`}
|
||||
</span>
|
||||
{commentsLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{commentsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{comments.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={includeCommentsInAnalysis}
|
||||
onCheckedChange={setIncludeCommentsInAnalysis}
|
||||
/>
|
||||
Include in AI analysis
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{commentsExpanded && (
|
||||
<div className="mt-3">
|
||||
{commentsError ? (
|
||||
<p className="text-sm text-red-500">{commentsError}</p>
|
||||
) : comments.length === 0 && !commentsLoading ? (
|
||||
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More Comments'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open in GitHub CTA */}
|
||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Plus,
|
||||
GitPullRequest,
|
||||
Clock,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
@@ -149,6 +152,77 @@ export function ValidationDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PR Analysis Section - Show AI's analysis of linked PRs */}
|
||||
{validationResult.prAnalysis && validationResult.prAnalysis.hasOpenPR && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
validationResult.prAnalysis.recommendation === 'wait_for_merge'
|
||||
? 'bg-green-500/10 border-green-500/20'
|
||||
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
|
||||
? 'bg-yellow-500/10 border-yellow-500/20'
|
||||
: 'bg-purple-500/10 border-purple-500/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{validationResult.prAnalysis.recommendation === 'wait_for_merge' ? (
|
||||
<Clock className="h-5 w-5 text-green-500 shrink-0 mt-0.5" />
|
||||
) : validationResult.prAnalysis.recommendation === 'pr_needs_work' ? (
|
||||
<Wrench className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<GitPullRequest className="h-5 w-5 text-purple-500 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
validationResult.prAnalysis.recommendation === 'wait_for_merge'
|
||||
? 'text-green-500'
|
||||
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
|
||||
? 'text-yellow-500'
|
||||
: 'text-purple-500'
|
||||
)}
|
||||
>
|
||||
{validationResult.prAnalysis.recommendation === 'wait_for_merge'
|
||||
? 'Fix Ready - Wait for Merge'
|
||||
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
|
||||
? 'PR Needs Work'
|
||||
: 'Work in Progress'}
|
||||
</span>
|
||||
{validationResult.prAnalysis.prNumber && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
PR #{validationResult.prAnalysis.prNumber}
|
||||
{validationResult.prAnalysis.prFixesIssue && ' appears to fix this issue'}
|
||||
</p>
|
||||
)}
|
||||
{validationResult.prAnalysis.prSummary && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{validationResult.prAnalysis.prSummary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback Work in Progress Badge - Show when there's an open PR but no AI analysis */}
|
||||
{!validationResult.prAnalysis?.hasOpenPR &&
|
||||
issue.linkedPRs?.some((pr) => pr.state === 'open' || pr.state === 'OPEN') && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-purple-500/10 border border-purple-500/20">
|
||||
<GitPullRequest className="h-5 w-5 text-purple-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-purple-500">Work in Progress</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{issue.linkedPRs
|
||||
.filter((pr) => pr.state === 'open' || pr.state === 'OPEN')
|
||||
.map((pr) => `PR #${pr.number}`)
|
||||
.join(', ')}{' '}
|
||||
is open for this issue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasoning */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
@@ -218,12 +292,14 @@ export function ValidationDialog({
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{validationResult?.verdict === 'valid' && onConvertToTask && (
|
||||
<Button onClick={handleConvertToTask}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Convert to Task
|
||||
</Button>
|
||||
)}
|
||||
{validationResult?.verdict === 'valid' &&
|
||||
onConvertToTask &&
|
||||
validationResult?.prAnalysis?.recommendation !== 'wait_for_merge' && (
|
||||
<Button onClick={handleConvertToTask}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Convert to Task
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useGithubIssues } from './use-github-issues';
|
||||
export { useIssueValidation } from './use-issue-validation';
|
||||
export { useIssueComments } from './use-issue-comments';
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getElectronAPI, GitHubComment } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface UseIssueCommentsResult {
|
||||
comments: GitHubComment[];
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasNextPage: boolean;
|
||||
error: string | null;
|
||||
loadMore: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
|
||||
const { currentProject } = useAppStore();
|
||||
const [comments, setComments] = useState<GitHubComment[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [endCursor, setEndCursor] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const fetchComments = useCallback(
|
||||
async (cursor?: string) => {
|
||||
if (!currentProject?.path || !issueNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLoadingMore = !!cursor;
|
||||
|
||||
try {
|
||||
if (isMountedRef.current) {
|
||||
setError(null);
|
||||
if (isLoadingMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
}
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (api.github) {
|
||||
const result = await api.github.getIssueComments(
|
||||
currentProject.path,
|
||||
issueNumber,
|
||||
cursor
|
||||
);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
if (result.success) {
|
||||
if (isLoadingMore) {
|
||||
// Append new comments
|
||||
setComments((prev) => [...prev, ...(result.comments || [])]);
|
||||
} else {
|
||||
// Replace all comments
|
||||
setComments(result.comments || []);
|
||||
}
|
||||
setTotalCount(result.totalCount || 0);
|
||||
setHasNextPage(result.hasNextPage || false);
|
||||
setEndCursor(result.endCursor);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch comments');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
console.error('[useIssueComments] Error fetching comments:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch comments');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentProject?.path, issueNumber]
|
||||
);
|
||||
|
||||
// Reset and fetch when issue changes
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (issueNumber) {
|
||||
// Reset state when issue changes
|
||||
setComments([]);
|
||||
setTotalCount(0);
|
||||
setHasNextPage(false);
|
||||
setEndCursor(undefined);
|
||||
setError(null);
|
||||
fetchComments();
|
||||
} else {
|
||||
// Clear comments when no issue is selected
|
||||
setComments([]);
|
||||
setTotalCount(0);
|
||||
setHasNextPage(false);
|
||||
setEndCursor(undefined);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [issueNumber, fetchComments]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (hasNextPage && endCursor && !loadingMore) {
|
||||
fetchComments(endCursor);
|
||||
}
|
||||
}, [hasNextPage, endCursor, loadingMore, fetchComments]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setComments([]);
|
||||
setEndCursor(undefined);
|
||||
fetchComments();
|
||||
}, [fetchComments]);
|
||||
|
||||
return {
|
||||
comments,
|
||||
totalCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasNextPage,
|
||||
error,
|
||||
loadMore,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
getElectronAPI,
|
||||
GitHubIssue,
|
||||
GitHubComment,
|
||||
IssueValidationResult,
|
||||
IssueValidationEvent,
|
||||
StoredValidation,
|
||||
} from '@/lib/electron';
|
||||
import type { LinkedPRInfo } from '@automaker/types';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { isValidationStale } from '../utils';
|
||||
@@ -205,8 +207,15 @@ export function useIssueValidation({
|
||||
}, []);
|
||||
|
||||
const handleValidateIssue = useCallback(
|
||||
async (issue: GitHubIssue, options: { forceRevalidate?: boolean } = {}) => {
|
||||
const { forceRevalidate = false } = options;
|
||||
async (
|
||||
issue: GitHubIssue,
|
||||
options: {
|
||||
forceRevalidate?: boolean;
|
||||
comments?: GitHubComment[];
|
||||
linkedPRs?: LinkedPRInfo[];
|
||||
} = {}
|
||||
) => {
|
||||
const { forceRevalidate = false, comments, linkedPRs } = options;
|
||||
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
@@ -236,14 +245,17 @@ export function useIssueValidation({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.github?.validateIssue) {
|
||||
const validationInput = {
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
issueBody: issue.body || '',
|
||||
issueLabels: issue.labels.map((l) => l.name),
|
||||
comments, // Include comments if provided
|
||||
linkedPRs, // Include linked PRs if provided
|
||||
};
|
||||
const result = await api.github.validateIssue(
|
||||
currentProject.path,
|
||||
{
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
issueBody: issue.body || '',
|
||||
issueLabels: issue.labels.map((l) => l.name),
|
||||
},
|
||||
validationInput,
|
||||
validationModel
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
|
||||
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
||||
import type { LinkedPRInfo } from '@automaker/types';
|
||||
|
||||
export interface IssueRowProps {
|
||||
issue: GitHubIssue;
|
||||
@@ -12,17 +13,25 @@ export interface IssueRowProps {
|
||||
isValidating?: boolean;
|
||||
}
|
||||
|
||||
/** Options for issue validation */
|
||||
export interface ValidateIssueOptions {
|
||||
showDialog?: boolean;
|
||||
forceRevalidate?: boolean;
|
||||
/** Include comments in AI analysis */
|
||||
comments?: GitHubComment[];
|
||||
/** Linked pull requests */
|
||||
linkedPRs?: LinkedPRInfo[];
|
||||
}
|
||||
|
||||
export interface IssueDetailPanelProps {
|
||||
issue: GitHubIssue;
|
||||
validatingIssues: Set<number>;
|
||||
cachedValidations: Map<number, StoredValidation>;
|
||||
onValidateIssue: (
|
||||
issue: GitHubIssue,
|
||||
options?: { showDialog?: boolean; forceRevalidate?: boolean }
|
||||
) => Promise<void>;
|
||||
onValidateIssue: (issue: GitHubIssue, options?: ValidateIssueOptions) => Promise<void>;
|
||||
onViewCachedValidation: (issue: GitHubIssue) => Promise<void>;
|
||||
onOpenInGitHub: (url: string) => void;
|
||||
onClose: () => void;
|
||||
onShowRevalidateConfirm: () => void;
|
||||
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
|
||||
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
|
||||
formatDate: (date: string) => string;
|
||||
}
|
||||
|
||||
110
apps/ui/src/components/views/login-view.tsx
Normal file
110
apps/ui/src/components/views/login-view.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Login View - Web mode authentication
|
||||
*
|
||||
* Prompts user to enter the API key shown in server console.
|
||||
* On successful login, sets an HTTP-only session cookie.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { login } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
export function LoginView() {
|
||||
const navigate = useNavigate();
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await login(apiKey.trim());
|
||||
if (result.success) {
|
||||
// Redirect to home/board on success
|
||||
navigate({ to: '/' });
|
||||
} else {
|
||||
setError(result.error || 'Invalid API key');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to connect to server');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<KeyRound className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-bold tracking-tight">Authentication Required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Enter the API key shown in the server console to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="apiKey" className="text-sm font-medium">
|
||||
API Key
|
||||
</label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="Enter API key..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
className="font-mono"
|
||||
data-testid="login-api-key-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !apiKey.trim()}
|
||||
data-testid="login-submit-button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
'Login'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4 text-sm">
|
||||
<p className="font-medium">Where to find the API key:</p>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-muted-foreground">
|
||||
<li>Look at the server terminal/console output</li>
|
||||
<li>Find the box labeled "API Key for Web Mode Authentication"</li>
|
||||
<li>Copy the UUID displayed there</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react';
|
||||
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||
import { getElectronAPI, RunningAgent } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
||||
|
||||
export function RunningAgentsView() {
|
||||
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
||||
const { setCurrentProject, projects } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -94,6 +96,10 @@ export function RunningAgentsView() {
|
||||
[projects, setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleViewLogs = useCallback((agent: RunningAgent) => {
|
||||
setSelectedAgent(agent);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@@ -156,15 +162,25 @@ export function RunningAgentsView() {
|
||||
</div>
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{agent.featureId}</span>
|
||||
<span className="font-medium truncate" title={agent.title || agent.featureId}>
|
||||
{agent.title || agent.featureId}
|
||||
</span>
|
||||
{agent.isAutoMode && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
|
||||
AUTO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p
|
||||
className="text-sm text-muted-foreground truncate max-w-md"
|
||||
title={agent.description}
|
||||
>
|
||||
{agent.description}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleNavigateToProject(agent)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -177,6 +193,15 @@ export function RunningAgentsView() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewLogs(agent)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
View Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -199,6 +224,20 @@ export function RunningAgentsView() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Output Modal */}
|
||||
{selectedAgent && (
|
||||
<AgentOutputModal
|
||||
open={true}
|
||||
onClose={() => setSelectedAgent(null)}
|
||||
projectPath={selectedAgent.projectPath}
|
||||
featureDescription={
|
||||
selectedAgent.description || selectedAgent.title || selectedAgent.featureId
|
||||
}
|
||||
featureId={selectedAgent.featureId}
|
||||
featureStatus="running"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
import { useCliStatus, useSettingsView } from './settings-view/hooks';
|
||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||
@@ -18,6 +19,8 @@ import { AudioSection } from './settings-view/audio/audio-section';
|
||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
|
||||
@@ -52,13 +55,22 @@ export function SettingsView() {
|
||||
setAutoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
setEnableSandboxMode,
|
||||
promptCustomization,
|
||||
setPromptCustomization,
|
||||
} = useAppStore();
|
||||
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
// Only show if CLI has been verified/authenticated
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const isCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
@@ -116,6 +128,15 @@ export function SettingsView() {
|
||||
{showUsageTracking && <ClaudeUsageSection />}
|
||||
</div>
|
||||
);
|
||||
case 'mcp-servers':
|
||||
return <MCPServersSection />;
|
||||
case 'prompts':
|
||||
return (
|
||||
<PromptCustomizationSection
|
||||
promptCustomization={promptCustomization}
|
||||
onPromptCustomizationChange={setPromptCustomization}
|
||||
/>
|
||||
);
|
||||
case 'ai-enhancement':
|
||||
return <AIEnhancementSection />;
|
||||
case 'appearance':
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
FlaskConical,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Plug,
|
||||
MessageSquareText,
|
||||
} from 'lucide-react';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
|
||||
@@ -22,6 +24,8 @@ export interface NavigationItem {
|
||||
export const NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||
{ id: 'claude', label: 'Claude', icon: Terminal },
|
||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
||||
{ id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useState, useCallback } from 'react';
|
||||
export type SettingsViewId =
|
||||
| 'api-keys'
|
||||
| 'claude'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'ai-enhancement'
|
||||
| 'appearance'
|
||||
| 'terminal'
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { MCPServerHeader } from './mcp-server-header';
|
||||
export { MCPPermissionSettings } from './mcp-permission-settings';
|
||||
export { MCPToolsWarning } from './mcp-tools-warning';
|
||||
export { MCPServerCard } from './mcp-server-card';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { syncSettingsToServer } from '@/hooks/use-settings-migration';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MCPPermissionSettingsProps {
|
||||
mcpAutoApproveTools: boolean;
|
||||
mcpUnrestrictedTools: boolean;
|
||||
onAutoApproveChange: (checked: boolean) => void;
|
||||
onUnrestrictedChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function MCPPermissionSettings({
|
||||
mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools,
|
||||
onAutoApproveChange,
|
||||
onUnrestrictedChange,
|
||||
}: MCPPermissionSettingsProps) {
|
||||
const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 border-b border-border/50 bg-muted/20">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
id="mcp-auto-approve"
|
||||
checked={mcpAutoApproveTools}
|
||||
onCheckedChange={async (checked) => {
|
||||
onAutoApproveChange(checked);
|
||||
await syncSettingsToServer();
|
||||
}}
|
||||
data-testid="mcp-auto-approve-toggle"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="mcp-auto-approve" className="text-sm font-medium cursor-pointer">
|
||||
Auto-approve MCP tool calls
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the AI agent can use MCP tools without permission prompts.
|
||||
</p>
|
||||
{mcpAutoApproveTools && (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Bypasses normal permission checks
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
id="mcp-unrestricted"
|
||||
checked={mcpUnrestrictedTools}
|
||||
onCheckedChange={async (checked) => {
|
||||
onUnrestrictedChange(checked);
|
||||
await syncSettingsToServer();
|
||||
}}
|
||||
data-testid="mcp-unrestricted-toggle"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="mcp-unrestricted" className="text-sm font-medium cursor-pointer">
|
||||
Unrestricted tool access
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the AI agent can use any tool, not just the default set.
|
||||
</p>
|
||||
{mcpUnrestrictedTools && (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Agent has full tool access including file writes and bash
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasAnyEnabled && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 mt-2',
|
||||
'text-xs text-amber-700 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
<p className="font-medium mb-1">Security Note</p>
|
||||
<p>
|
||||
These settings reduce security restrictions for MCP tool usage. Only enable if you
|
||||
trust all configured MCP servers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MCPServerConfig } from '@automaker/types';
|
||||
import type { ServerTestState } from '../types';
|
||||
import { getServerIcon, getTestStatusIcon, maskSensitiveUrl } from '../utils';
|
||||
import { MCPToolsList } from '../mcp-tools-list';
|
||||
|
||||
interface MCPServerCardProps {
|
||||
server: MCPServerConfig;
|
||||
testState?: ServerTestState;
|
||||
isExpanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onTest: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
onEditJson: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function MCPServerCard({
|
||||
server,
|
||||
testState,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
onTest,
|
||||
onToggleEnabled,
|
||||
onEditJson,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: MCPServerCardProps) {
|
||||
const Icon = getServerIcon(server.type);
|
||||
const hasTools = testState?.tools && testState.tools.length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border',
|
||||
server.enabled !== false
|
||||
? 'border-border/50 bg-accent/20'
|
||||
: 'border-border/30 bg-muted/30 opacity-60'
|
||||
)}
|
||||
data-testid={`mcp-server-${server.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 overflow-hidden">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-3 text-left min-w-0 flex-1',
|
||||
hasTools && 'cursor-pointer hover:opacity-80'
|
||||
)}
|
||||
disabled={!hasTools}
|
||||
>
|
||||
{hasTools ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
||||
server.enabled !== false ? 'bg-brand-500/20' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm truncate">{server.name}</span>
|
||||
{testState && getTestStatusIcon(testState.status)}
|
||||
{testState?.status === 'success' && testState.tools && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
{testState.tools.length} tool{testState.tools.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{server.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{server.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground/60 mt-0.5 truncate">
|
||||
{server.type === 'stdio'
|
||||
? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}`
|
||||
: maskSensitiveUrl(server.url || '')}
|
||||
</div>
|
||||
{testState?.status === 'error' && testState.error && (
|
||||
<div className="text-xs text-destructive mt-1 line-clamp-2 break-words">
|
||||
{testState.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={testState?.status === 'testing' || server.enabled === false}
|
||||
data-testid={`mcp-server-test-${server.id}`}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
{testState?.status === 'testing' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-1.5 text-xs">Test</span>
|
||||
</Button>
|
||||
<Switch
|
||||
checked={server.enabled !== false}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
data-testid={`mcp-server-toggle-${server.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onEditJson}
|
||||
title="Edit JSON"
|
||||
data-testid={`mcp-server-json-${server.id}`}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onEdit}
|
||||
data-testid={`mcp-server-edit-${server.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
data-testid={`mcp-server-delete-${server.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{hasTools && (
|
||||
<CollapsibleContent>
|
||||
<div className="px-4 pb-4 pt-0 ml-7 overflow-hidden">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Available Tools</div>
|
||||
<MCPToolsList
|
||||
tools={testState.tools!}
|
||||
isLoading={testState.status === 'testing'}
|
||||
error={testState.error}
|
||||
className="max-w-full"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MCPServerHeaderProps {
|
||||
isRefreshing: boolean;
|
||||
hasServers: boolean;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
onEditAllJson: () => void;
|
||||
onImport: () => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export function MCPServerHeader({
|
||||
isRefreshing,
|
||||
hasServers,
|
||||
onRefresh,
|
||||
onExport,
|
||||
onEditAllJson,
|
||||
onImport,
|
||||
onAdd,
|
||||
}: MCPServerHeaderProps) {
|
||||
return (
|
||||
<div className="p-6 border-b border-border/50 bg-linear-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-linear-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Plug className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">MCP Servers</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure Model Context Protocol servers to extend agent capabilities.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
data-testid="refresh-mcp-servers-button"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
{hasServers && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onExport}
|
||||
data-testid="export-mcp-servers-button"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onEditAllJson}
|
||||
data-testid="edit-all-json-button"
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
Edit JSON
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onImport}
|
||||
data-testid="import-mcp-servers-button"
|
||||
>
|
||||
<FileJson className="w-4 h-4 mr-2" />
|
||||
Import JSON
|
||||
</Button>
|
||||
<Button size="sm" onClick={onAdd} data-testid="add-mcp-server-button">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { MAX_RECOMMENDED_TOOLS } from '../constants';
|
||||
|
||||
interface MCPToolsWarningProps {
|
||||
totalTools: number;
|
||||
}
|
||||
|
||||
export function MCPToolsWarning({ totalTools }: MCPToolsWarningProps) {
|
||||
return (
|
||||
<div className="mx-6 mt-4 p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">
|
||||
High tool count detected ({totalTools} tools)
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Having more than {MAX_RECOMMENDED_TOOLS} MCP tools may degrade AI model performance.
|
||||
Consider disabling unused servers or removing unnecessary tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user