Files
sencho/backend/src/index.ts
T

865 lines
28 KiB
TypeScript

import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import WebSocket from 'ws';
import jwt from 'jsonwebtoken';
import DockerController from './services/DockerController';
import { FileSystemService } from './services/FileSystemService';
import { ComposeService } from './services/ComposeService';
import { ConfigService } from './services/ConfigService';
// @ts-ignore - composerize lacks proper type definitions
import composerize from 'composerize';
import si from 'systeminformation';
import http from 'http';
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { HostTerminalService } from './services/HostTerminalService';
import { DatabaseService } from './services/DatabaseService';
import { NotificationService } from './services/NotificationService';
import { MonitorService } from './services/MonitorService';
const execAsync = promisify(exec);
const app = express();
const PORT = 3000;
// ConfigService for persistent auth storage
const configService = new ConfigService();
// FileSystemService for stack management
const fileSystemService = new FileSystemService();
// ComposeService for stack operations
const composeService = new ComposeService();
// Cookie settings
const COOKIE_NAME = 'sencho_token';
// Helper to determine if request is secure (HTTPS or behind a proxy that terminates SSL)
const isSecureRequest = (req: Request): boolean => {
return req.secure || req.headers['x-forwarded-proto'] === 'https';
};
// Helper to get cookie options dynamically per-request
const getCookieOptions = (req: Request) => ({
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'strict' as const,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
});
// Middleware
app.use(cors({
origin: true,
credentials: true,
}));
app.use(express.json());
app.use(cookieParser());
// Extend Express Request type for user
declare module 'express' {
interface Request {
user?: { username: string };
}
}
// Authentication Middleware
const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const token = req.cookies[COOKIE_NAME];
if (!token) {
res.status(401).json({ error: 'Authentication required' });
return;
}
try {
const jwtSecret = await configService.getJwtSecret();
const decoded = jwt.verify(token, jwtSecret) as { username: string };
req.user = { username: decoded.username };
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
return;
}
};
// Auth Routes (no authentication required)
// Check if setup is needed
app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> => {
try {
const needsSetup = await configService.needsSetup();
res.json({ needsSetup });
} catch (error) {
console.error('Error checking setup status:', error);
res.json({ needsSetup: true });
}
});
// Initial setup endpoint
app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> => {
try {
// Check if setup is still needed
const needsSetup = await configService.needsSetup();
if (!needsSetup) {
res.status(400).json({ error: 'Setup has already been completed' });
return;
}
const { username, password, confirmPassword } = req.body;
// Validation
if (!username || !password || !confirmPassword) {
res.status(400).json({ error: 'All fields are required' });
return;
}
if (username.length < 3) {
res.status(400).json({ error: 'Username must be at least 3 characters' });
return;
}
if (password.length < 6) {
res.status(400).json({ error: 'Password must be at least 6 characters' });
return;
}
if (password !== confirmPassword) {
res.status(400).json({ error: 'Passwords do not match' });
return;
}
// Save credentials (this also generates the JWT secret)
await configService.saveConfig(username, password);
// Issue JWT and log user in
const jwtSecret = await configService.getJwtSecret();
const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
res.json({ success: true, message: 'Setup completed successfully' });
} catch (error) {
console.error('Setup error:', error);
res.status(500).json({ error: 'Failed to complete setup' });
}
});
// Login endpoint
app.post('/api/auth/login', async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
try {
const isValid = await configService.validateCredentials(username, password);
if (isValid) {
const jwtSecret = await configService.getJwtSecret();
const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
res.json({ success: true, message: 'Login successful' });
return;
}
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
app.post('/api/auth/logout', (req: Request, res: Response): void => {
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'strict',
});
res.json({ success: true, message: 'Logged out successfully' });
});
// Check authentication status
app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void => {
res.json({ authenticated: true, user: req.user });
});
// Apply authentication middleware to all /api/* routes except /api/auth/*
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/')) {
next();
return;
}
authMiddleware(req, res, next);
});
// Create HTTP server for WebSocket upgrade handling
const server = http.createServer(app);
// WebSocket server with authentication
const wss = new WebSocket.Server({ noServer: true });
let terminalWs: WebSocket | null = null;
// Handle WebSocket upgrade with JWT authentication
server.on('upgrade', async (req, socket, head) => {
// Parse cookies from the upgrade request
const cookieHeader = req.headers.cookie || '';
const cookies = Object.fromEntries(
cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v)
);
const token = cookies[COOKIE_NAME];
if (!token) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
try {
const jwtSecret = await configService.getJwtSecret();
jwt.verify(token, jwtSecret);
// Check if this is a stack logs WebSocket request
const url = req.url || '';
const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/);
const hostConsoleMatch = url.match(/^\/api\/system\/host-console/);
if (logsMatch) {
// Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs
const logsWss = new WebSocket.Server({ noServer: true });
logsWss.handleUpgrade(req, socket, head, (ws) => {
const stackName = decodeURIComponent(logsMatch[1]);
try {
composeService.streamLogs(stackName, ws);
} catch (error) {
console.error('Failed to stream logs:', error);
if (ws.readyState === WebSocket.OPEN) {
ws.send(`Error streaming logs: ${(error as Error).message}\n`);
}
}
});
} else if (hostConsoleMatch) {
const hostConsoleWss = new WebSocket.Server({ noServer: true });
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
let targetDirectory = fileSystemService.getBaseDir();
try {
const reqUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
const stackParam = reqUrl.searchParams.get('stack');
if (stackParam) {
targetDirectory = path.join(targetDirectory, stackParam);
}
} catch (e) {
// ignore parsing error, fallback to base dir
}
try {
HostTerminalService.spawnTerminal(ws, targetDirectory);
} catch (error) {
console.error('Failed to spawn host terminal:', error);
if (ws.readyState === WebSocket.OPEN) {
ws.send(`Error spawning terminal: ${(error as Error).message}\r\n`);
ws.close();
}
}
});
} else {
// Generic terminal WebSocket
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
}
} catch (error) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
});
wss.on('connection', (ws) => {
console.log('WebSocket connected');
ws.on('message', (message) => {
try {
const data = JSON.parse(message.toString());
// Only handle 'action'-based messages at the global level.
// 'type'-based messages (input, resize, ping) are handled by the
// per-session listener registered inside execContainer's closure.
if (!data.action) return;
if (data.action === 'connectTerminal') {
terminalWs = ws;
} else if (data.action === 'streamStats') {
const dockerController = DockerController.getInstance();
dockerController.streamStats(data.containerId, ws);
} else if (data.action === 'execContainer') {
// Handle container exec for bash access
// Input, resize, and cleanup are handled inside execContainer's closure
const dockerController = DockerController.getInstance();
dockerController.execContainer(data.containerId, ws);
}
} catch (error) {
// Malformed JSON — ignore silently
}
});
});
// API Routes (all protected by authMiddleware)
app.get('/api/containers', async (req: Request, res: Response) => {
try {
const dockerController = DockerController.getInstance();
const containers = await dockerController.getRunningContainers();
res.json(containers);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch containers' });
}
});
// Stack Routes - Updated to use stackName (directory name) instead of filename
app.get('/api/stacks', async (req: Request, res: Response) => {
try {
const stacks = await fileSystemService.getStacks();
res.json(stacks);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stacks' });
}
});
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
const content = await fileSystemService.getStackContent(stackName);
res.send(content);
} catch (error) {
res.status(500).json({ error: 'Failed to read stack' });
}
});
app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const { content } = req.body;
console.log('PUT /api/stacks/:stackName', { stackName, contentType: typeof content, contentLength: content?.length });
if (typeof content !== 'string') {
console.error('Content is not a string:', content);
return res.status(400).json({ error: 'Content must be a string' });
}
await fileSystemService.saveStackContent(stackName, content);
console.log('Stack saved successfully:', stackName);
res.json({ message: 'Stack saved successfully' });
} catch (error) {
console.error('Failed to save stack:', error);
res.status(500).json({ error: 'Failed to save stack' });
}
});
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
const exists = await fileSystemService.envExists(stackName);
if (!exists) {
return res.status(404).json({ error: 'Env file not found' });
}
const content = await fileSystemService.getEnvContent(stackName);
res.send(content);
} catch (error) {
res.status(500).json({ error: 'Failed to read env file' });
}
});
app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const { content } = req.body;
if (typeof content !== 'string') {
return res.status(400).json({ error: 'Content must be a string' });
}
await fileSystemService.saveEnvContent(stackName, content);
res.json({ message: 'Env file saved successfully' });
} catch (error) {
console.error('Failed to save env file:', error);
res.status(500).json({ error: 'Failed to save env file' });
}
});
app.post('/api/stacks', async (req: Request, res: Response) => {
try {
const { stackName } = req.body;
if (!stackName || typeof stackName !== 'string') {
return res.status(400).json({ error: 'Stack name is required and must be a string' });
}
if (!/^[a-zA-Z0-9-]+$/.test(stackName)) {
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters and hyphens' });
}
await fileSystemService.createStack(stackName);
res.json({ message: 'Stack created successfully', name: stackName });
} catch (error: any) {
if (error.message && error.message.includes('already exists')) {
return res.status(409).json({ error: 'Stack already exists' });
}
console.error('Failed to create stack:', error);
res.status(500).json({ error: 'Failed to create stack' });
}
});
app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
// Tear down the stack first to avoid ghost containers
try {
console.log(`Tearing down stack: ${stackName}`);
// Send the down command synchronously before deleting the files
await composeService.runCommand(stackName, 'down', terminalWs || undefined);
} catch (downError) {
console.warn(`Failed to tear down stack ${stackName}, proceeding with file deletion.`, downError);
}
await fileSystemService.deleteStack(stackName);
res.json({ message: 'Stack deleted successfully' });
} catch (error) {
console.error('Failed to delete stack:', error);
res.status(500).json({ error: 'Failed to delete stack' });
}
});
app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance();
const containers = await dockerController.getContainersByStack(stackName);
res.json(containers);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch containers' });
}
});
app.post('/api/containers/:id/start', async (req: Request, res: Response) => {
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance();
await dockerController.startContainer(id);
res.json({ message: 'Container started' });
} catch (error) {
res.status(500).json({ error: 'Failed to start container' });
}
});
app.post('/api/containers/:id/stop', async (req: Request, res: Response) => {
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance();
await dockerController.stopContainer(id);
res.json({ message: 'Container stopped' });
} catch (error) {
res.status(500).json({ error: 'Failed to stop container' });
}
});
app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance();
await dockerController.restartContainer(id);
res.json({ message: 'Container restarted' });
} catch (error) {
res.status(500).json({ error: 'Failed to restart container' });
}
});
// End of legacy container routes
app.post('/api/stacks/:stackName/up', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
composeService.runCommand(stackName, 'up', terminalWs || undefined);
res.json({ status: 'Command started' });
} catch (error) {
res.status(500).json({ error: 'Failed to start command' });
}
});
app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
composeService.runCommand(stackName, 'down', terminalWs || undefined);
res.json({ status: 'Command started' });
} catch (error) {
res.status(500).json({ error: 'Failed to start command' });
}
});
// Direct container restart - bypasses docker compose for legacy container support
app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance();
const containers = await dockerController.getContainersByStack(stackName);
if (containers && containers.length > 0) {
for (const c of containers) {
await dockerController.restartContainer(c.Id);
}
}
res.json({ status: 'Containers restarted' });
} catch (error) {
console.error('Failed to restart containers:', error);
res.status(500).json({ error: 'Failed to restart containers' });
}
});
// Direct container stop - bypasses docker compose for legacy container support
// Only stops containers that are currently running to avoid 304 errors
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance();
const containers = await dockerController.getContainersByStack(stackName);
if (containers && containers.length > 0) {
for (const c of containers) {
if (c.State === 'running') {
await dockerController.stopContainer(c.Id);
}
}
}
res.json({ status: 'Containers stopped' });
} catch (error) {
console.error('Failed to stop containers:', error);
res.status(500).json({ error: 'Failed to stop containers' });
}
});
// Direct container start - bypasses docker compose for legacy container support
// Only starts containers that are not currently running
app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance();
const containers = await dockerController.getContainersByStack(stackName);
if (containers && containers.length > 0) {
for (const c of containers) {
if (c.State !== 'running') {
await dockerController.startContainer(c.Id);
}
}
}
res.json({ status: 'Containers started' });
} catch (error) {
console.error('Failed to start containers:', error);
res.status(500).json({ error: 'Failed to start containers' });
}
});
// Update stack: pull images and recreate containers
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
// Run update asynchronously, don't wait for completion
composeService.updateStack(stackName, terminalWs || undefined).catch(error => {
console.error('Update stack error:', error);
});
res.json({ status: 'Update started' });
} catch (error) {
res.status(500).json({ error: 'Failed to start update' });
}
});
// Docker Run to Compose converter endpoint
app.post('/api/convert', async (req: Request, res: Response) => {
try {
const { dockerRun } = req.body;
if (!dockerRun || typeof dockerRun !== 'string') {
return res.status(400).json({ error: 'dockerRun command is required' });
}
const yaml = composerize(dockerRun);
res.json({ yaml });
} catch (error) {
console.error('Conversion error:', error);
res.status(500).json({ error: 'Failed to convert docker run command' });
}
});
// Get all containers stats for dashboard
app.get('/api/stats', async (req: Request, res: Response) => {
try {
const dockerController = DockerController.getInstance();
const containers = await dockerController.getRunningContainers();
const allContainers = await dockerController.getAllContainers();
const active = containers.length;
const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length;
const total = allContainers.length;
res.json({ active, exited, total, inactive: total - active - exited });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stats' });
}
});
// Get host system stats
app.get('/api/system/stats', async (req: Request, res: Response) => {
try {
const [currentLoad, mem, fsSize, networkStats] = await Promise.all([
si.currentLoad(),
si.mem(),
si.fsSize(),
si.networkStats(),
]);
let rxBytes = 0;
let txBytes = 0;
let rxSec = 0;
let txSec = 0;
networkStats.forEach(net => {
rxBytes += net.rx_bytes || 0;
txBytes += net.tx_bytes || 0;
rxSec += net.rx_sec || 0;
txSec += net.tx_sec || 0;
});
// Find the main mount (usually the largest or root mount)
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
res.json({
cpu: {
usage: currentLoad.currentLoad.toFixed(1),
cores: currentLoad.cpus.length,
},
memory: {
total: mem.total,
used: mem.used,
free: mem.free,
usagePercent: ((mem.used / mem.total) * 100).toFixed(1),
},
disk: mainDisk ? {
fs: mainDisk.fs,
mount: mainDisk.mount,
total: mainDisk.size,
used: mainDisk.used,
free: mainDisk.available,
usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0',
} : null,
network: {
rxBytes,
txBytes,
rxSec,
txSec
}
});
} catch (error) {
console.error('Failed to fetch system stats:', error);
res.status(500).json({ error: 'Failed to fetch system stats' });
}
});
// --- Notification & Alerting Routes ---
app.get('/api/stacks', async (req: Request, res: Response) => {
try {
const stacks = await fileSystemService.getStacks();
res.json(stacks);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stacks' });
}
});
app.get('/api/agents', async (req: Request, res: Response) => {
try {
const agents = DatabaseService.getInstance().getAgents();
res.json(agents);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch agents' });
}
});
app.post('/api/agents', async (req: Request, res: Response) => {
try {
const agent = req.body;
DatabaseService.getInstance().upsertAgent(agent);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to update agent' });
}
});
app.get('/api/settings', async (req: Request, res: Response) => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
res.json(settings);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch settings' });
}
});
app.post('/api/settings', async (req: Request, res: Response) => {
try {
const { key, value } = req.body;
DatabaseService.getInstance().updateGlobalSetting(key, value);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to update setting' });
}
});
app.get('/api/alerts', async (req: Request, res: Response) => {
try {
let stackName = req.query.stackName as string | undefined;
if (Array.isArray(stackName)) stackName = stackName[0] as string;
const alerts = DatabaseService.getInstance().getStackAlerts(stackName);
res.json(alerts);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch alerts' });
}
});
app.post('/api/alerts', async (req: Request, res: Response) => {
try {
const alert = req.body;
DatabaseService.getInstance().addStackAlert(alert);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to add alert' });
}
});
app.delete('/api/alerts/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string, 10);
DatabaseService.getInstance().deleteStackAlert(id);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete alert' });
}
});
app.get('/api/notifications', async (req: Request, res: Response) => {
try {
const history = DatabaseService.getInstance().getNotificationHistory();
res.json(history);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch notifications' });
}
});
app.post('/api/notifications/read', async (req: Request, res: Response) => {
try {
DatabaseService.getInstance().markAllNotificationsRead();
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to mark notifications read' });
}
});
app.post('/api/notifications/test', async (req: Request, res: Response) => {
try {
const { type, url } = req.body;
await NotificationService.getInstance().testDispatch(type, url);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: 'Test failed', details: error.message });
}
});
// --- System Maintenance Routes (The System Janitor) ---
app.get('/api/system/orphans', async (req: Request, res: Response) => {
try {
const knownStacks = await fileSystemService.getStacks();
const dockerController = DockerController.getInstance();
const orphans = await dockerController.getOrphanContainers(knownStacks);
res.json(orphans);
} catch (error) {
console.error('Failed to fetch orphan containers:', error);
res.status(500).json({ error: 'Failed to fetch orphan containers' });
}
});
app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
try {
const { containerIds } = req.body;
if (!Array.isArray(containerIds)) {
return res.status(400).json({ error: 'containerIds must be an array' });
}
const dockerController = DockerController.getInstance();
const results = await dockerController.removeContainers(containerIds);
res.json({ results });
} catch (error) {
console.error('Failed to prune orphan containers:', error);
res.status(500).json({ error: 'Failed to prune orphan containers' });
}
});
app.post('/api/system/prune/system', async (req: Request, res: Response) => {
try {
const { target } = req.body; // 'containers', 'images', 'networks'
let command = '';
if (target === 'containers') {
command = 'docker container prune -f';
} else if (target === 'images') {
command = 'docker image prune -a -f';
} else if (target === 'networks') {
command = 'docker network prune -f';
} else {
return res.status(400).json({ error: 'Invalid prune target' });
}
const { stdout, stderr } = await execAsync(command, {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
res.json({ message: 'Prune completed', stdout, stderr });
} catch (error: any) {
console.error('System prune error:', error);
res.status(500).json({ error: 'System prune failed', details: error.message });
}
});
// Serve static files in production (for Docker deployment)
if (process.env.NODE_ENV === 'production') {
app.use(express.static('public'));
// Handle SPA routing - serve index.html for non-API routes
// Using app.use middleware instead of app.get('*') for path-to-regexp compatibility
app.use((req: Request, res: Response) => {
if (!req.path.startsWith('/api')) {
res.sendFile('index.html', { root: 'public' });
}
});
}
// Start server with migration
async function startServer() {
try {
// Run migration before starting server
console.log('Running stack migration check...');
await fileSystemService.migrateFlatToDirectory();
console.log('Migration check completed');
} catch (error) {
console.error('Migration failed:', error);
// Continue starting server even if migration fails
}
// Start Background Watchdog
MonitorService.getInstance().start();
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
startServer();