UNPKG

@rubys/fly-explorer

Version:

A comprehensive web-based dashboard for managing Fly.io infrastructure using Model Context Protocol (MCP) integration with flyctl

1,342 lines (1,341 loc) 68.8 kB
import express from 'express'; import cors from 'cors'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { randomUUID } from 'crypto'; import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'fs'; import { exec, execSync } from 'child_process'; import { promisify } from 'util'; import { FlyctlMCPClient } from '../lib/flyctl-client.js'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const execAsync = promisify(exec); const app = express(); const port = process.env.PORT || 3001; app.use(cors()); app.use(express.json()); // Serve static files from the dist directory in production // In npm package: __dirname is dist/server, we want to serve from dist/ (parent dir) const distPath = path.join(__dirname, '..'); app.use(express.static(distPath)); let mcpClient = null; // Check if flyctl is installed async function checkFlyctl() { try { execSync('flyctl version', { stdio: 'ignore' }); return true; } catch { return false; } } // Install flyctl async function installFlyctl() { console.log('flyctl not found in PATH. Installing...'); try { const platform = process.platform; if (platform === 'darwin' || platform === 'linux') { // Download and execute the install script directly console.log('Downloading flyctl install script...'); try { // Fetch the install script const response = await fetch('https://fly.io/install.sh'); if (!response.ok) { throw new Error(`Failed to download install script: ${response.statusText}`); } const scriptContent = await response.text(); // Write script to a temporary file and execute it const tmpDir = process.env.TMPDIR || process.env.TMP || process.env.TEMP || '/tmp'; const scriptPath = path.join(tmpDir, `fly-install-${Date.now()}.sh`); writeFileSync(scriptPath, scriptContent, { mode: 0o755 }); console.log('Running flyctl installer...'); const { stdout, stderr } = await execAsync(`sh ${scriptPath}`); // Clean up temporary file try { unlinkSync(scriptPath); } catch { } if (stderr && !stderr.includes('successfully')) { throw new Error(stderr); } // Add to PATH for current session const homeDir = process.env.HOME; const flyctlPath = `${homeDir}/.fly/bin`; process.env.PATH = `${flyctlPath}:${process.env.PATH}`; console.log('flyctl installed successfully!'); console.log('Note: You may need to restart your terminal or add ~/.fly/bin to your PATH permanently.'); } catch (fetchError) { // Fallback to curl if fetch fails console.log('Direct download failed, trying curl fallback...'); const { stdout, stderr } = await execAsync('curl -L https://fly.io/install.sh | sh'); if (stderr && !stderr.includes('successfully')) { throw new Error(stderr); } // Add to PATH for current session const homeDir = process.env.HOME; const flyctlPath = `${homeDir}/.fly/bin`; process.env.PATH = `${flyctlPath}:${process.env.PATH}`; console.log('flyctl installed successfully!'); console.log('Note: You may need to restart your terminal or add ~/.fly/bin to your PATH permanently.'); } } else if (platform === 'win32') { // For Windows, use PowerShell console.log('Installing flyctl using PowerShell...'); const { stdout, stderr } = await execAsync('powershell -Command "iwr https://fly.io/install.ps1 -useb | iex"'); if (stderr && !stderr.includes('successfully')) { throw new Error(stderr); } console.log('flyctl installed successfully!'); console.log('Note: You may need to restart your terminal for the PATH changes to take effect.'); } else { throw new Error(`Unsupported platform: ${platform}`); } // Verify installation and add to PATH if needed const installed = await ensureFlyctlInPath(); if (!installed) { throw new Error('flyctl installation verification failed'); } return true; } catch (error) { console.error('Failed to install flyctl:', error); console.error('\nPlease install flyctl manually:'); console.error(' macOS/Linux: curl -L https://fly.io/install.sh | sh'); console.error(' Windows: powershell -Command "iwr https://fly.io/install.ps1 -useb | iex"'); console.error(' Or visit: https://fly.io/docs/flyctl/install/'); return false; } } // Check if flyctl exists in common locations and add to PATH if needed async function ensureFlyctlInPath() { // First check if it's already in PATH try { execSync('flyctl version', { stdio: 'ignore' }); return true; } catch { // Not in PATH, check common locations const homeDir = process.env.HOME || process.env.USERPROFILE; const platform = process.platform; const possiblePaths = [ `${homeDir}/.fly/bin/flyctl`, // Default install location `${homeDir}\\.fly\\bin\\flyctl.exe`, // Windows default '/usr/local/bin/flyctl', // Homebrew or manual install '/opt/homebrew/bin/flyctl', // Homebrew on Apple Silicon ]; for (const flyctlPath of possiblePaths) { try { execSync(`"${flyctlPath}" version`, { stdio: 'ignore' }); // Found flyctl, add its directory to PATH const flyctlDir = path.dirname(flyctlPath); process.env.PATH = `${flyctlDir}:${process.env.PATH}`; console.log(`Found flyctl at ${flyctlPath}, added to PATH`); return true; } catch { // Continue checking other paths } } return false; } } // Initialize MCP client async function initializeMCPClient() { try { // First check if flyctl is available in PATH or can be added const flyctlAvailable = await ensureFlyctlInPath(); if (!flyctlAvailable) { console.log('flyctl not found in common locations, attempting to install...'); const installed = await installFlyctl(); if (!installed) { throw new Error('flyctl is required but could not be installed automatically'); } } else { console.log('flyctl is available'); } // Check authentication status - this will prompt for login if needed console.log('Checking Fly.io authentication...'); try { const { stdout } = await execAsync('flyctl auth whoami'); const email = stdout.trim(); if (email && email.includes('@')) { console.log(`Authenticated as: ${email}`); } else { console.log('Authentication successful'); } } catch (authError) { // If auth whoami fails, it might be because user needs to log in if (authError.message.includes('not logged in') || authError.code === 1) { console.log('Not logged in to Fly.io. Please log in using the prompt that appears.'); console.log('After logging in, restart the application.'); // The flyctl auth whoami command will have already shown the login prompt process.exit(1); } else { console.error('Authentication check failed:', authError.message); } } mcpClient = new FlyctlMCPClient(); // Use flyctl from system PATH await mcpClient.connect(); console.log('MCP client connected successfully'); } catch (error) { console.error('Failed to connect MCP client:', error); process.exit(1); } } // Helper function to call MCP tools async function callTool(toolName, args, parseJson = true, options) { if (!mcpClient) { throw new Error('MCP client not initialized'); } const client = mcpClient.rawClient; const params = { name: toolName, arguments: args }; // Add _meta if provided in options if (options?._meta) { params._meta = options._meta; } // Use request instead of callTool to avoid schema validation issues const request = { method: 'tools/call', params: params }; let response; if (toolName === 'fly-logs') { // Pass timeout as request option for fly-logs (24 hours) response = await client.request(request, CallToolResultSchema, { timeout: 24 * 60 * 60 * 1000, resetTimeoutOnProgress: true }); } else { response = await client.request(request, CallToolResultSchema); } if (response.content && Array.isArray(response.content) && response.content.length > 0) { const content = response.content[0]; if (content.type === 'text') { if (parseJson) { try { return JSON.parse(content.text); } catch (e) { // If JSON parsing fails, return the raw text return { raw_text: content.text }; } } else { return content.text; } } } throw new Error('Invalid response from tool'); } // Helper function to call MCP tools with notification support async function callToolWithNotifications(toolName, args, progressToken, notificationCallback, timeoutMs) { if (!mcpClient) { throw new Error('MCP client not initialized'); } // Store the original progress handler const originalProgressHandler = mcpClient.onProgress; // Set up progress callback for this specific token mcpClient.onProgress = (progress) => { // Only handle notifications for our specific token if (progress.progressToken === progressToken && notificationCallback) { notificationCallback(progress); } // Also call the original handler if it exists if (originalProgressHandler) { originalProgressHandler(progress); } }; try { const options = { _meta: { progressToken: progressToken } }; let response; if (timeoutMs) { // Create a timeout promise that never rejects for fly-logs response = await callTool(toolName, args, false, options); } else { response = await callTool(toolName, args, false, options); } return response; } finally { // Restore the original handler mcpClient.onProgress = originalProgressHandler; } } // API Routes app.get('/api/organizations', async (req, res) => { try { const orgs = await callTool('fly-orgs-list', {}); // Convert object format {"slug": "name"} to array format const orgArray = Object.entries(orgs).map(([slug, name]) => ({ slug, name: typeof name === 'string' ? name : slug, type: 'organization' })); res.json(orgArray); } catch (error) { console.error('Error fetching organizations:', error); res.status(500).json({ error: 'Failed to fetch organizations' }); } }); app.get('/api/organizations/:org', async (req, res) => { try { // Get organization details and apps const [orgsData, apps] = await Promise.all([ callTool('fly-orgs-list', {}), callTool('fly-apps-list', { org: req.params.org }) ]); // Find the specific organization const orgSlug = req.params.org; const orgName = orgsData[orgSlug] || orgSlug; // Transform the app data const transformedApps = apps.map((app) => ({ id: app.ID || app.id, name: app.Name || app.name, status: app.Status || app.status, deployed: app.Deployed || false, hostname: app.Hostname || '', platformVersion: app.PlatformVersion || '' })); res.json({ slug: orgSlug, name: orgName, type: 'organization', appCount: transformedApps.length, apps: transformedApps }); } catch (error) { console.error('Error fetching organization details:', error); res.status(500).json({ error: 'Failed to fetch organization details' }); } }); app.get('/api/organizations/:org/apps', async (req, res) => { try { const apps = await callTool('fly-apps-list', { org: req.params.org }); // Transform the app data to expected format const transformedApps = apps.map((app) => ({ id: app.ID || app.id, name: app.Name || app.name, status: app.Status || app.status, organization: { slug: req.params.org } })); res.json(transformedApps); } catch (error) { console.error('Error fetching apps:', error); res.status(500).json({ error: 'Failed to fetch apps' }); } }); app.get('/api/apps/:app/machines', async (req, res) => { try { const machines = await callTool('fly-machine-list', { app: req.params.app }); // Transform the machines data to expected format const transformedMachines = Array.isArray(machines) ? machines.map((machine) => ({ id: machine.id || machine.ID, name: machine.name || machine.Name || machine.id || machine.ID, state: machine.state || machine.State || 'unknown', region: machine.region || machine.Region || 'unknown', created_at: machine.created_at || machine.CreatedAt || new Date().toISOString() })) : []; res.json(transformedMachines); } catch (error) { console.error('Error fetching machines:', error); res.status(500).json({ error: 'Failed to fetch machines', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.get('/api/apps/:app/machines/:machineId', async (req, res) => { try { const machineStatus = await callTool('fly-machine-status', { app: req.params.app, id: req.params.machineId, 'display-config': true }); // If we got raw text, parse it and structure it if (machineStatus.raw_text) { const parsedData = parseMachineStatusText(machineStatus.raw_text); res.json(parsedData); } else { res.json(machineStatus); } } catch (error) { console.error('Error fetching machine details:', error); res.status(500).json({ error: 'Failed to fetch machine details', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Helper function to parse machine status text function parseMachineStatusText(text) { const lines = text.split('\n'); let id = '', name = '', state = '', region = '', image = '', instance_id = '', private_ip = ''; let cpu_kind = '', vcpus = '', memory = '', created = ''; let eventSection = false; let configSection = false; let configText = ''; let events = []; for (const line of lines) { if (line.includes('Machine ID:')) { id = line.split(':')[1]?.trim() || ''; } else if (line.includes('Instance ID:')) { instance_id = line.split(':')[1]?.trim() || ''; } else if (line.includes('State')) { const parts = line.split('='); if (parts.length > 1) { state = parts[1]?.trim() || ''; } else { state = line.split(':')[1]?.trim() || ''; } } else if (line.includes('Image') && line.includes('=')) { image = line.split('=')[1]?.trim() || ''; } else if (line.includes('Name') && line.includes('=')) { name = line.split('=')[1]?.trim() || ''; } else if (line.includes('Private IP') && line.includes('=')) { private_ip = line.split('=')[1]?.trim() || ''; } else if (line.includes('Region') && line.includes('=')) { region = line.split('=')[1]?.trim() || ''; } else if (line.includes('CPU Kind') && line.includes('=')) { cpu_kind = line.split('=')[1]?.trim() || ''; } else if (line.includes('vCPUs') && line.includes('=')) { vcpus = line.split('=')[1]?.trim() || ''; } else if (line.includes('Memory') && line.includes('=')) { memory = line.split('=')[1]?.trim() || ''; } else if (line.includes('Created') && line.includes('=')) { created = line.split('=')[1]?.trim() || ''; } else if (line.includes('Event Logs')) { eventSection = true; } else if (line.includes('Config:')) { configSection = true; eventSection = false; } else if (configSection) { configText += line + '\n'; } else if (eventSection && line.trim() && !line.includes('STATE') && !line.includes('---')) { // Parse event lines like: "started start flyd 2025-06-08T17:55:09.699-04:00" const parts = line.trim().split(/\s+/); if (parts.length >= 4) { events.push({ status: parts[0], type: parts[1], source: parts[2], timestamp: parts[3] }); } } } return { id, name: name || id, state: state.toLowerCase(), region, image, instance_id, private_ip, cpu_kind, vcpus, memory, created, config: configText.trim() ? { raw: configText.trim() } : null, events, checks: [], raw_text: text }; } app.get('/api/apps/:app/status', async (req, res) => { try { const status = await callTool('fly-status', { app: req.params.app }); res.json(status); } catch (error) { console.error('Error fetching app status:', error); res.status(500).json({ error: 'Failed to fetch app status' }); } }); app.get('/api/apps/:app/volumes', async (req, res) => { try { const volumes = await callTool('fly-volumes-list', { app: req.params.app }); // Transform the volumes data to expected format const transformedVolumes = Array.isArray(volumes) ? volumes.map((volume) => ({ id: volume.id || volume.ID, name: volume.name || volume.Name || volume.id || volume.ID, size_gb: volume.size_gb || volume.SizeGb || volume['size-gb'] || volume.size, region: volume.region || volume.Region, state: volume.state || volume.State || 'unknown', attached: volume.attached || volume.Attached || (volume.attached_machine_id || volume.AttachedMachineId || volume['attached-machine-id']) ? true : false, attached_machine_id: volume.attached_machine_id || volume.AttachedMachineId || volume['attached-machine-id'], created_at: volume.created_at || volume.CreatedAt || new Date().toISOString() })) : []; res.json(transformedVolumes); } catch (error) { console.error('Error fetching volumes:', error); res.status(500).json({ error: 'Failed to fetch volumes' }); } }); app.get('/api/apps/:app/secrets', async (req, res) => { try { const secrets = await callTool('fly-secrets-list', { app: req.params.app }); // Transform the secrets data to expected format const transformedSecrets = Array.isArray(secrets) ? secrets.map((secret) => ({ name: secret.Name || secret.name, created_at: secret.CreatedAt || secret.created_at, digest: secret.Digest || secret.digest })) : []; res.json(transformedSecrets); } catch (error) { console.error('Error fetching secrets:', error); res.status(500).json({ error: 'Failed to fetch secrets' }); } }); app.get('/api/apps/:app/certificates', async (req, res) => { try { const certs = await callTool('fly-certs-list', { app: req.params.app }); res.json(certs); } catch (error) { console.error('Error fetching certificates:', error); res.status(500).json({ error: 'Failed to fetch certificates' }); } }); app.get('/api/apps/:app/releases', async (req, res) => { try { const releases = await callTool('fly-apps-releases', { name: req.params.app }); // If we got raw text, parse it and structure it if (releases.raw_text) { const parsedReleases = parseReleasesText(releases.raw_text); res.json(parsedReleases); } else if (Array.isArray(releases)) { // Transform the JSON response to expected format const transformedReleases = releases.map((release) => ({ id: release.ID || release.id, version: release.Version || release.version, status: release.Status || release.status, created_at: release.CreatedAt || release.created_at, user: { email: release.User?.Email || release.user?.email || 'Unknown' } })); res.json(transformedReleases); } else { res.json([]); } } catch (error) { console.error('Error fetching releases:', error); res.status(500).json({ error: 'Failed to fetch releases', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Helper function to parse releases text function parseReleasesText(text) { const lines = text.split('\n'); const releases = []; for (const line of lines) { if (line.trim() && !line.includes('VERSION') && !line.includes('---')) { // Parse release lines like: "v1231 2025-06-08T21:55:09Z success user@example.com" const parts = line.trim().split(/\s+/); if (parts.length >= 4) { releases.push({ version: parts[0], created_at: parts[1], status: parts[2], user: { email: parts[3] }, id: parts[0] }); } } } return releases; } app.get('/api/apps/:app/logs', async (req, res) => { const { machine, region, lines, stream } = req.query; // If not streaming, use the regular response if (stream !== 'true') { try { const params = { app: req.params.app }; if (machine) params.machine = machine; if (region) params.region = region; const logs = await callTool('fly-logs', params, false); // Don't parse as JSON // Split logs into individual entries const logLines = logs.split('\n') .filter((line) => line.trim()) .slice(-(parseInt(lines) || 100)) // Get last N lines .map((line, index) => ({ id: index, timestamp: extractTimestamp(line), message: line, messageHtml: ansiToHtml(line), level: extractLogLevel(line) })); res.json(logLines); } catch (error) { console.error('Error fetching logs:', error); res.status(500).json({ error: 'Failed to fetch logs', details: error instanceof Error ? error.message : 'Unknown error' }); } return; } // Set up SSE for streaming logs with progress res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' // Disable nginx buffering }); // Disable timeout for this specific request req.setTimeout(0); res.setTimeout(0); // Send initial connection message res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`); try { const params = { app: req.params.app }; if (machine) params.machine = machine; if (region) params.region = region; // Generate progress token const progressToken = randomUUID(); // Track progress messages const progressMessages = []; // Call tool with notification support (timeout resets on progress) const logs = await callToolWithNotifications('fly-logs', params, progressToken, (notification) => { const message = notification.message || notification.params?.message; if (message) { progressMessages.push(message); // Send progress notification via SSE res.write(`data: ${JSON.stringify({ type: 'progress', message: message, params: notification })}\n\n`); } }); // Process the final logs const logLines = logs.split('\n') .filter((line) => line.trim()) .slice(-(parseInt(lines) || 100)) .map((line, index) => ({ id: index, timestamp: extractTimestamp(line), message: line, messageHtml: ansiToHtml(line), level: extractLogLevel(line) })); // Send the complete logs res.write(`data: ${JSON.stringify({ type: 'complete', logs: logLines, progressMessages: progressMessages })}\n\n`); res.end(); } catch (error) { console.error('Error fetching logs:', error); res.write(`data: ${JSON.stringify({ type: 'error', error: 'Failed to fetch logs', details: error instanceof Error ? error.message : 'Unknown error' })}\n\n`); res.end(); } }); // Machine control endpoints app.post('/api/apps/:app/machines/:machineId/start', async (req, res) => { try { const result = await callTool('fly-machine-start', { app: req.params.app, id: req.params.machineId }); res.json({ success: true, result }); } catch (error) { console.error('Error starting machine:', error); res.status(500).json({ error: 'Failed to start machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.post('/api/apps/:app/machines/:machineId/stop', async (req, res) => { try { const result = await callTool('fly-machine-stop', { app: req.params.app, id: req.params.machineId }); res.json({ success: true, result }); } catch (error) { console.error('Error stopping machine:', error); res.status(500).json({ error: 'Failed to stop machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.post('/api/apps/:app/machines/:machineId/restart', async (req, res) => { try { const result = await callTool('fly-machine-restart', { app: req.params.app, id: req.params.machineId }); res.json({ success: true, result }); } catch (error) { console.error('Error restarting machine:', error); res.status(500).json({ error: 'Failed to restart machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Secrets management endpoints app.post('/api/apps/:app/secrets', async (req, res) => { try { const { secrets } = req.body; // Build the arguments for fly-secrets-set // The tool expects keyvalues as an array of "KEY=VALUE" strings const keyvalues = Object.entries(secrets).map(([key, value]) => { return `${key}=${value}`; }); const args = { app: req.params.app, keyvalues: keyvalues }; const result = await callTool('fly-secrets-set', args); res.json({ success: true, result }); } catch (error) { console.error('Error setting secrets:', error); res.status(500).json({ error: 'Failed to set secrets', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.delete('/api/apps/:app/secrets/:secretName', async (req, res) => { try { const result = await callTool('fly-secrets-unset', { app: req.params.app, names: [req.params.secretName] }); res.json({ success: true, result }); } catch (error) { console.error('Error unsetting secret:', error); res.status(500).json({ error: 'Failed to unset secret', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Deploy endpoint app.post('/api/apps/:app/deploy', async (req, res) => { try { const result = await callTool('fly-secrets-deploy', { app: req.params.app }); res.json({ success: true, result }); } catch (error) { console.error('Error deploying app:', error); res.status(500).json({ error: 'Failed to deploy app', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Volume management endpoints app.post('/api/apps/:app/volumes', async (req, res) => { try { const { name, region, size_gb, machines } = req.body; const args = { app: req.params.app, name: name, region: region, size: size_gb }; // Add machines array if provided if (machines && machines.length > 0) { args.machines = machines; } const result = await callTool('fly-volumes-create', args); res.json({ success: true, result }); } catch (error) { console.error('Error creating volume:', error); res.status(500).json({ error: 'Failed to create volume', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.delete('/api/apps/:app/volumes/:volumeId', async (req, res) => { try { const result = await callTool('fly-volumes-destroy', { app: req.params.app, id: req.params.volumeId }); res.json({ success: true, result }); } catch (error) { console.error('Error destroying volume:', error); res.status(500).json({ error: 'Failed to destroy volume', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.put('/api/apps/:app/volumes/:volumeId/extend', async (req, res) => { try { const { size_gb } = req.body; const result = await callTool('fly-volumes-extend', { app: req.params.app, id: req.params.volumeId, size: size_gb }); res.json({ success: true, result }); } catch (error) { console.error('Error extending volume:', error); res.status(500).json({ error: 'Failed to extend volume', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.get('/api/apps/:app/volumes/:volumeId', async (req, res) => { try { const result = await callTool('fly-volumes-show', { app: req.params.app, id: req.params.volumeId }); res.json(result); } catch (error) { console.error('Error fetching volume details:', error); res.status(500).json({ error: 'Failed to fetch volume details', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Platform regions endpoint app.get('/api/platform/regions', async (req, res) => { try { const regions = await callTool('fly-platform-regions', {}); res.json(regions); } catch (error) { console.error('Error fetching platform regions:', error); res.status(500).json({ error: 'Failed to fetch platform regions', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Platform VM sizes endpoint app.get('/api/platform/vm-sizes', async (req, res) => { try { const vmSizes = await callTool('fly-platform-vm-sizes', {}); res.json(vmSizes); } catch (error) { console.error('Error fetching VM sizes:', error); res.status(500).json({ error: 'Failed to fetch VM sizes', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Platform status endpoint app.get('/api/platform/status', async (req, res) => { try { const status = await callTool('fly-platform-status', {}); res.json(status); } catch (error) { console.error('Error fetching platform status:', error); res.status(500).json({ error: 'Failed to fetch platform status', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // App management endpoints app.post('/api/organizations/:org/apps', async (req, res) => { try { const result = await callTool('fly-apps-create', { org: req.params.org, ...req.body }); res.json({ success: true, result }); } catch (error) { console.error('Error creating app:', error); res.status(500).json({ error: 'Failed to create app', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.delete('/api/apps/:app', async (req, res) => { try { const result = await callTool('fly-apps-destroy', { name: req.params.app }); res.json({ success: true, result }); } catch (error) { console.error('Error destroying app:', error); res.status(500).json({ error: 'Failed to destroy app', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.put('/api/apps/:app/move', async (req, res) => { try { const result = await callTool('fly-apps-move', { name: req.params.app, ...req.body }); res.json({ success: true, result }); } catch (error) { console.error('Error moving app:', error); res.status(500).json({ error: 'Failed to move app', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Machine lifecycle endpoints app.post('/api/apps/:app/machines', async (req, res) => { try { const result = await callTool('fly-machine-create', { app: req.params.app, ...req.body }); res.json({ success: true, result }); } catch (error) { console.error('Error creating machine:', error); res.status(500).json({ error: 'Failed to create machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.delete('/api/apps/:app/machines/:machineId', async (req, res) => { try { const result = await callTool('fly-machine-destroy', { app: req.params.app, id: req.params.machineId, force: req.query.force === 'true' }); res.json({ success: true, result }); } catch (error) { console.error('Error destroying machine:', error); res.status(500).json({ error: 'Failed to destroy machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.post('/api/apps/:app/machines/:machineId/clone', async (req, res) => { try { const result = await callTool('fly-machine-clone', { app: req.params.app, id: req.params.machineId, ...req.body }); res.json({ success: true, result }); } catch (error) { console.error('Error cloning machine:', error); res.status(500).json({ error: 'Failed to clone machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.post('/api/apps/:app/machines/:machineId/cordon', async (req, res) => { try { const result = await callTool('fly-machine-cordon', { app: req.params.app, id: req.params.machineId }); res.json({ success: true, result }); } catch (error) { console.error('Error cordoning machine:', error); res.status(500).json({ error: 'Failed to cordon machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.post('/api/apps/:app/machines/:machineId/uncordon', async (req, res) => { try { const result = await callTool('fly-machine-uncordon', { app: req.params.app, id: req.params.machineId }); res.json({ success: true, result }); } catch (error) { console.error('Error uncordoning machine:', error); res.status(500).json({ error: 'Failed to uncordon machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); app.put('/api/apps/:app/machines/:machineId', async (req, res) => { try { const result = await callTool('fly-machine-update', { app: req.params.app, id: req.params.machineId, ...req.body }); res.json({ success: true, result }); } catch (error) { console.error('Error updating machine:', error); res.status(500).json({ error: 'Failed to update machine', details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Helper functions for log parsing function extractTimestamp(logLine) { const timestampMatch = logLine.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[\.\d]*Z?)/); return timestampMatch ? timestampMatch[1] : new Date().toISOString(); } function extractLogLevel(logLine) { const line = logLine.toLowerCase(); if (line.includes('error') || line.includes('err')) return 'error'; if (line.includes('warn') || line.includes('warning')) return 'warn'; if (line.includes('info')) return 'info'; if (line.includes('debug')) return 'debug'; return 'info'; } // ANSI color codes to HTML conversion function ansiToHtml(text) { // Remove ANSI escape sequences and convert to HTML const ansiRegex = /\x1b\[([0-9;]*)m/g; let html = ''; let lastIndex = 0; let currentStyles = []; const ansiColorMap = { // Foreground colors '30': 'color: #000000', // black '31': 'color: #e74c3c', // red '32': 'color: #2ecc71', // green '33': 'color: #f1c40f', // yellow '34': 'color: #3498db', // blue '35': 'color: #9b59b6', // magenta '36': 'color: #1abc9c', // cyan '37': 'color: #ecf0f1', // white '90': 'color: #7f8c8d', // bright black (gray) '91': 'color: #ff6b6b', // bright red '92': 'color: #51cf66', // bright green '93': 'color: #ffd93d', // bright yellow '94': 'color: #74c0fc', // bright blue '95': 'color: #d0bfff', // bright magenta '96': 'color: #66d9ef', // bright cyan '97': 'color: #ffffff', // bright white // Background colors '40': 'background-color: #000000', // black '41': 'background-color: #e74c3c', // red '42': 'background-color: #2ecc71', // green '43': 'background-color: #f1c40f', // yellow '44': 'background-color: #3498db', // blue '45': 'background-color: #9b59b6', // magenta '46': 'background-color: #1abc9c', // cyan '47': 'background-color: #ecf0f1', // white '100': 'background-color: #7f8c8d', // bright black (gray) '101': 'background-color: #ff6b6b', // bright red '102': 'background-color: #51cf66', // bright green '103': 'background-color: #ffd93d', // bright yellow '104': 'background-color: #74c0fc', // bright blue '105': 'background-color: #d0bfff', // bright magenta '106': 'background-color: #66d9ef', // bright cyan '107': 'background-color: #ffffff', // bright white // Text formatting '1': 'font-weight: bold', '2': 'opacity: 0.7', '3': 'font-style: italic', '4': 'text-decoration: underline', }; let match; while ((match = ansiRegex.exec(text)) !== null) { // Add text before the ANSI code if (match.index > lastIndex) { const textBefore = text.slice(lastIndex, match.index); if (currentStyles.length > 0) { html += `<span style="${currentStyles.join('; ')}">${escapeHtml(textBefore)}</span>`; } else { html += escapeHtml(textBefore); } } // Parse the ANSI code const codes = match[1].split(';'); for (const code of codes) { if (code === '0' || code === '') { // Reset all styles currentStyles = []; } else if (ansiColorMap[code]) { // Add or update style const style = ansiColorMap[code]; const property = style.split(':')[0]; // Remove existing style of the same property currentStyles = currentStyles.filter(s => !s.startsWith(property)); currentStyles.push(style); } } lastIndex = match.index + match[0].length; } // Add remaining text if (lastIndex < text.length) { const remainingText = text.slice(lastIndex); if (currentStyles.length > 0) { html += `<span style="${currentStyles.join('; ')}">${escapeHtml(remainingText)}</span>`; } else { html += escapeHtml(remainingText); } } return html; } function escapeHtml(text) { return text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } // List available tools app.get('/api/tools', async (req, res) => { try { if (!mcpClient) { return res.status(500).json({ error: 'MCP client not initialized' }); } const tools = await mcpClient.listTools(); res.json(tools); } catch (error) { console.error('Error listing tools:', error); res.status(500).json({ error: 'Failed to list tools' }); } }); // Execute any MCP tool app.post('/api/tools/:toolName/execute', async (req, res) => { try { if (!mcpClient) { return res.status(500).json({ error: 'MCP client not initialized' }); } const { toolName } = req.params; const { arguments: toolArgs } = req.body; const result = await callTool(toolName, toolArgs || {}); res.json({ success: true, result }); } catch (error) { console.error(`Error executing tool ${req.params.toolName}:`, error); res.status(500).json({ error: `Failed to execute tool ${req.params.toolName}`, details: error instanceof Error ? error.message : 'Unknown error' }); } }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', mcpConnected: mcpClient !== null }); }); // Settings management // Always use ~/.fly/explorer/settings.json for settings const getSettingsPath = () => { const homeDir = process.env.HOME || process.env.USERPROFILE || ''; return path.join(homeDir, '.fly', 'explorer', 'settings.json'); }; const settingsFile = getSettingsPath(); let settings = { provider: 'openai', apiKey: '' }; // Function to load settings from file function loadSettings() { try { if (existsSync(settingsFile)) { const data = readFileSync(settingsFile, 'utf8'); const savedSettings = JSON.parse(data); if (savedSettings.provider && savedSettings.apiKey) { settings.provider = savedSettings.provider; settings.apiKey = savedSettings.apiKey; return; } } } catch (error) { console.error('Failed to load settings from file:', error); } // Fallback to environment variables if no saved settings if (process.env.OPENAI_API_KEY) { settings.provider = 'openai'; settings.apiKey = process.env.OPENAI_API_KEY; } else if (process.env.ANTHROPIC_API_KEY) { settings.provider = 'anthropic'; settings.apiKey = process.env.ANTHROPIC_API_KEY; } else if (process.env.GEMINI_API_KEY) { settings.provider = 'gemini'; settings.apiKey = process.env.GEMINI_API_KEY; } else if (process.env.COHERE_API_KEY) { settings.provider = 'cohere'; settings.apiKey = process.env.COHERE_API_KEY; } else if (process.env.MISTRAL_API_KEY) { settings.provider = 'mistral'; settings.apiKey = process.env.MISTRAL_API_KEY; } } // Function to save settings to file function saveSettings() { try { // Always ensure directory exists const settingsDir = path.dirname(settingsFile); if (!existsSync(settingsDir)) { mkdirSync(settingsDir, { recursive: true }); } writeFileSync(settingsFile, JSON.stringify(settings, null, 2)); return true; } catch (error) { console.error('Failed to save settings to file:', error); throw error; // Re-throw the error so the endpoint can handle it } } // Load settings on startup loadSettings(); // Settings endpoints app.get('/api/settings', (req, res) => { res.json({ provider: settings.provider, hasApiKey: !!settings.apiKey, apiKeyMask: settings.apiKey ? '***' + settings.apiKey.slice(-4) : '' }); }); app.post('/api/settings', (req, res) => { const { provider, apiKey } = req.body; if (provider && apiKey) { try { settings.provider = provider; settings.apiKey = apiKey; saveSettings(); // Persist to file res.json({ success: true }); } catch (error) { console.error('Error saving settings:', error); res.status(500).json({ error: 'Failed to save settings to disk' }); } } else { res.status(400).json({ error: 'Provider and API key are required' }); } }); app.get('/api/settings/api-key/status', (req, res) => { res.json({ hasApiKey: !!settings.apiKey }); }); // Debug endpoint to see current settings app.get('/api/settings/debug', (req, res) => { res.json({ provider: settings.provider, hasApiKey: !!settings.apiKey, apiKeyLength: settings.apiKey.length, apiKeyPreview: settings.apiKey ? settings.apiKey.substring(0, 6) + '...' : 'none' }); }); app.post('/api/settings/test', async (req, res) => { // Use settings from request body if provided, otherwise use stored settings const testSettings = req.body && req.body.apiKey ? req.body : settings; if (!testSettings.apiKey) { return res.status(400).json({ success: false, error: 'No API key configured' }); } try { if (testSettings.provider === 'openai') { const { OpenAI } = await import('openai'); const openai = new OpenAI({ apiKey: testSettings.apiKey }); await openai.models.list(); } else if (testSettings.provider === 'anthropic') { const { Anthropic } = await import('@anthropic-ai/sdk'); const anthropic = new Anthropic({ apiKey: testSettings.apiKey }); await anthropic.messages.create({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'test' }] }); } else if (testSettings.provider === 'gemini') { const { GoogleGenerativeAI } = await import('@google/generative-ai'); const genAI = new GoogleGenerativeAI(testSettings.apiKey); const model = genAI.getGenerativeModel({ model: 'gemini-pro' }); await model.generateContent('test'); } else if (testSettings.provider === 'cohere') { const { CohereClient } = await import('cohere-ai'); const cohere = new CohereClient({ token: testSettings.apiKey }); await cohere.chat({ message: 'test', maxTokens: 1 }); } else if (testSettings.provider === 'mistral') {