// @ts-check import fs from "fs"; import path from "path"; import { spawn, ChildProcess } from "child_process"; import { WebSocketServer, WebSocket, RawData } from 'ws'; import http from 'http'; import express from 'express'; const LOG_DIR: string = path.resolve(__dirname, "../models/data"); const LOG_FILE: string = path.join(LOG_DIR, "logs.txt"); let processInstance: ChildProcess | null = null; let isRunning: boolean = false; let isAwaitingInput: boolean = false; const INPUT_PROMPT_STRING = "__NEEDS_INPUT__"; interface ApiType { clearLogFile: () => void; broadcast: (msg: string | Buffer, wss?: WebSocketServer, isError?: boolean, silent?: boolean) => void; parseExocoreRun: (filePath: string) => { exportCommands: string[]; runCommand: string | null }; executeCommand: ( command: string, cwd: string, onCloseCallback: (outcome: number | string | Error) => void, wss?: WebSocketServer ) => ChildProcess | null; runCommandsSequentially: ( commands: string[], cwd: string, onSequenceDone: (success: boolean) => void, wss?: WebSocketServer, index?: number ) => void; start: (wss?: WebSocketServer, args?: string) => void; stop: (wss?: WebSocketServer) => void; restart: (wss?: WebSocketServer, args?: string) => void; status: () => "running" | "stopped"; } const api: ApiType = { clearLogFile(): void { if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } fs.writeFileSync(LOG_FILE, ""); }, broadcast(msg: string | Buffer, wss?: WebSocketServer, isError: boolean = false, silent: boolean = false): void { const data = msg.toString(); if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } if (!data.includes(INPUT_PROMPT_STRING)) { fs.appendFileSync(LOG_FILE, `${data}`); } if (!silent && wss && wss.clients) { wss.clients.forEach((client: WebSocket) => { if (client.readyState === WebSocket.OPEN) { if (data.includes(INPUT_PROMPT_STRING)) { const cleanData = data.replace(INPUT_PROMPT_STRING, "").trim(); isAwaitingInput = true; client.send(JSON.stringify({ type: 'INPUT_REQUIRED', payload: cleanData })); } else { client.send(data); } } }); } if (isError) { console.error(`BROADCAST_ERROR_LOG: ${data.trim()}`); } }, parseExocoreRun(filePath: string): { exportCommands: string[]; runCommand: string | null } { const raw: string = fs.readFileSync(filePath, "utf8"); const exportMatch: RegExpMatchArray | null = raw.match(/export\s*=\s*{([\s\S]*?)}/); const functionMatch: RegExpMatchArray | null = raw.match(/function\s*=\s*{([\s\S]*?)}/); const exportCommands: string[] = []; if (exportMatch && exportMatch[1]) { const lines: string[] = exportMatch[1].split(";"); for (let line of lines) { const matchResult: RegExpMatchArray | null = line.match(/["'](.+?)["']/); if (matchResult && typeof matchResult[1] === 'string' && matchResult[1].trim() !== '') { exportCommands.push(matchResult[1]); } } } let runCommand: string | null = null; if (functionMatch && functionMatch[1]) { const runMatch: RegExpMatchArray | null = functionMatch[1].match(/run\s*=\s*["'](.+?)["']/); if (runMatch && typeof runMatch[1] === 'string' && runMatch[1].trim() !== '') { runCommand = runMatch[1]; } } return { exportCommands, runCommand }; }, executeCommand( command: string, cwd: string, onCloseCallback: (outcome: number | string | Error) => void, wss?: WebSocketServer ): ChildProcess | null { let proc: ChildProcess | null = null; try { proc = spawn(command, { cwd, shell: true, env: { ...process.env, FORCE_COLOR: "1", LANG: "en_US.UTF-8" }, }); } catch (rawErr: unknown) { let errMsg = "Unknown spawn error"; if (rawErr instanceof Error) errMsg = rawErr.message; else if (typeof rawErr === 'string') errMsg = rawErr; api.broadcast(`\x1b[31m❌ Spawn error for command "${command}": ${errMsg}\x1b[0m`, wss, true, false); if (typeof onCloseCallback === 'function') { if (rawErr instanceof Error) onCloseCallback(rawErr); else onCloseCallback(new Error(String(rawErr))); } return null; } if (proc.stdout) { proc.stdout.on("data", (data: Buffer | string) => api.broadcast(data, wss, false, false)); } if (proc.stderr) { proc.stderr.on("data", (data: Buffer | string) => api.broadcast(`\x1b[31m${data.toString()}\x1b[0m`, wss, true, false)); } proc.on("close", (code: number | null, signal: NodeJS.Signals | null) => { if (typeof onCloseCallback === 'function') { if (code === null) onCloseCallback(signal || 'signaled'); else onCloseCallback(code); } }); proc.on("error", (err: Error) => { api.broadcast(`\x1b[31m❌ Command execution error for "${command}": ${err.message}\x1b[0m`, wss, true, false); if (typeof onCloseCallback === 'function') onCloseCallback(err); }); return proc; }, runCommandsSequentially( commands: string[], cwd: string, onSequenceDone: (success: boolean) => void, wss?: WebSocketServer, index: number = 0 ): void { if (index >= commands.length) { if (typeof onSequenceDone === 'function') onSequenceDone(true); return; } const cmd = commands[index]; if (typeof cmd !== 'string' || cmd.trim() === "") { api.broadcast(`\x1b[31m❌ Invalid or empty setup command at index ${index}.\x1b[0m`, wss, true, false); if (typeof onSequenceDone === 'function') onSequenceDone(false); return; } const proc = api.executeCommand(cmd, cwd, (outcome: number | string | Error) => { const benignSignal = (typeof outcome === 'string' && (outcome.toUpperCase() === 'SIGTERM' || outcome.toUpperCase() === 'SIGKILL')); if (outcome !== 0 && (typeof outcome === 'number' || outcome instanceof Error || (typeof outcome === 'string' && !benignSignal))) { const errorMessage = outcome instanceof Error ? outcome.message : String(outcome); api.broadcast(`\x1b[31m❌ Setup command "${cmd}" failed: ${errorMessage}\x1b[0m`, wss, true, false); if (typeof onSequenceDone === 'function') onSequenceDone(false); return; } api.runCommandsSequentially(commands, cwd, onSequenceDone, wss, index + 1); }, wss ); if (!proc) { api.broadcast(`\x1b[31m❌ Failed to initiate setup command "${cmd}".\x1b[0m`, wss, true, false); if (typeof onSequenceDone === 'function') onSequenceDone(false); } }, start(wss?: WebSocketServer, args?: string): void { if (isRunning) { api.broadcast("\x1b[33m⚠️ Process is already running.\x1b[0m", wss, true, false); return; } api.clearLogFile(); api.broadcast(`\x1b[36m[SYSTEM] Starting process...\x1b[0m`, wss, false, true); isAwaitingInput = false; let projectPath: string | undefined; try { const configJsonPath: string = path.resolve(process.cwd(), 'config.json'); if (fs.existsSync(configJsonPath)) { const configRaw: string = fs.readFileSync(configJsonPath, 'utf-8'); if (configRaw.trim() !== "") { const configData: any = JSON.parse(configRaw); if (configData && typeof configData.project === 'string' && configData.project.trim() !== "") { const configJsonDir: string = path.dirname(configJsonPath); const resolvedPath: string = path.resolve(configJsonDir, configData.project); if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { projectPath = resolvedPath; } else { api.broadcast(`\x1b[31m❌ Error: Project path from config.json is invalid: ${resolvedPath}\x1b[0m`, wss, true, false); return; } } else { api.broadcast(`\x1b[31m❌ Error: 'project' key in config.json is missing or invalid.\x1b[0m`, wss, true, false); return; } } else { api.broadcast(`\x1b[31m❌ Error: config.json is empty.\x1b[0m`, wss, true, false); return; } } else { api.broadcast(`\x1b[31m❌ Error: Configuration file not found at ${configJsonPath}.\x1b[0m`, wss, true, false); return; } } catch (rawErr: unknown) { api.broadcast(`\x1b[31m❌ Error reading or parsing project configuration.\x1b[0m`, wss, true, false); return; } if (typeof projectPath !== 'string') { api.broadcast(`\x1b[31m❌ Project path could not be determined.\x1b[0m`, wss, true, false); return; } const currentProjectPath: string = projectPath; const exocoreRunPath: string = path.join(currentProjectPath, "exocore.run"); if (!fs.existsSync(exocoreRunPath)) { api.broadcast(`\x1b[31m❌ Missing exocore.run file in ${currentProjectPath}.\x1b[0m`, wss, true, false); return; } const { exportCommands, runCommand } = api.parseExocoreRun(exocoreRunPath); if (!runCommand) { api.broadcast("\x1b[31m❌ Missing run command in exocore.run.\x1b[0m", wss, true, false); return; } const currentRunCommand: string = args ? `${runCommand} ${args}` : runCommand; api.runCommandsSequentially([...exportCommands], currentProjectPath, (setupSuccess: boolean) => { if (!setupSuccess) { api.broadcast(`\x1b[31m❌ Setup commands failed. Main process will not start.\x1b[0m`, wss, true, false); return; } api.broadcast(`\x1b[36m[SYSTEM] Executing: ${currentRunCommand}\x1b[0m`, wss, false, true); let spawnedProc: ChildProcess | null = null; try { spawnedProc = spawn(currentRunCommand, { cwd: currentProjectPath, shell: true, detached: true, env: { ...process.env, FORCE_COLOR: "1", LANG: "en_US.UTF-8" }, }); } catch (rawErr: unknown) { api.broadcast(`\x1b[31m❌ Failed to spawn main process.\x1b[0m`, wss, true, false); return; } if (!spawnedProc || typeof spawnedProc.pid !== 'number') { api.broadcast(`\x1b[31m❌ Failed to get process handle.\x1b[0m`, wss, true, false); return; } processInstance = spawnedProc; isRunning = true; api.broadcast(`\x1b[32m[SYSTEM] Process started with PID: ${processInstance.pid}\x1b[0m`, wss, false, true); if (processInstance.stdout) { processInstance.stdout.on("data", (data: Buffer | string) => api.broadcast(data, wss, false, false)); } if (processInstance.stderr) { processInstance.stderr.on("data", (data: Buffer | string) => api.broadcast(`\x1b[31m${data.toString()}\x1b[0m`, wss, true, false) ); } processInstance.on("close", (code: number | null, signal: NodeJS.Signals | null) => { isRunning = false; processInstance = null; isAwaitingInput = false; const exitReason = signal ? `signal ${signal}` : `code ${code}`; api.broadcast(`\x1b[33m[SYSTEM] Process exited with ${exitReason}. It will not be restarted automatically.\x1b[0m`, wss, false, false); }); processInstance.on("error", (err: Error) => { api.broadcast(`\x1b[31m❌ Error with main process: ${err.message}\x1b[0m`, wss, true, false); isRunning = false; processInstance = null; isAwaitingInput = false; }); }, wss); }, stop(wss?: WebSocketServer): void { if (!processInstance || typeof processInstance.pid !== 'number') { api.broadcast("\x1b[33m⚠️ No active process to stop.\x1b[0m", wss, true, false); if (isRunning) { isRunning = false; processInstance = null; } return; } if (!isRunning) { api.broadcast("\x1b[33m⚠️ Process is already stopped.\x1b[0m", wss, true, false); return; } const pidToStop: number = processInstance.pid; api.broadcast(`\x1b[36m[SYSTEM] Stopping process group PID: ${pidToStop}...\x1b[0m`, wss, false, true); try { process.kill(-pidToStop, "SIGTERM"); setTimeout(() => { if (processInstance && processInstance.pid === pidToStop && !processInstance.killed) { api.broadcast(`\x1b[33m[SYSTEM] ⚠️ Process ${pidToStop} unresponsive, sending SIGKILL.\x1b[0m`, wss, true, false); try { process.kill(-pidToStop, "SIGKILL"); } catch (rawKillErr: unknown) { const err = rawKillErr as NodeJS.ErrnoException; if (err.code !== 'ESRCH') { api.broadcast(`\x1b[31m[SYSTEM] ❌ Error sending SIGKILL to PID ${pidToStop}: ${err.message}\x1b[0m`, wss, true, false); } } finally { if (processInstance && processInstance.pid === pidToStop) { isRunning = false; processInstance = null; } } } }, 3000); } catch (rawTermErr: unknown) { const err = rawTermErr as NodeJS.ErrnoException; if (err.code !== 'ESRCH') { api.broadcast(`\x1b[31m❌ Error sending SIGTERM: ${err.message}\x1b[0m`, wss, true, false); } isRunning = false; processInstance = null; } }, restart(wss?: WebSocketServer, args?: string): void { api.broadcast(`\x1b[36m[SYSTEM] Restarting process...\x1b[0m`, wss, false, true); api.stop(wss); setTimeout(() => { api.start(wss, args); }, 1000); }, status(): "running" | "stopped" { if (isRunning && processInstance && !processInstance.killed) { try { process.kill(processInstance.pid!, 0); return "running"; } catch (e) { isRunning = false; processInstance = null; return "stopped"; } } return "stopped"; }, }; export interface RouteHandlerParamsSuperset { app?: express.Application; req: express.Request; res: express.Response; wss?: WebSocketServer; wssConsole?: WebSocketServer; Shellwss?: WebSocketServer; server?: http.Server; } export interface ExpressRouteModule { method: "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "all"; path: string; install: (params: Partial) => void; } type StartStopRestartParams = Pick; type ConsoleStatusParams = Pick; export const modules: Array<{ method: "get" | "post"; path: string; install: (params: any) => void; }> = [ { method: "post", path: "/start", install: ({ req, res, wssConsole }: StartStopRestartParams) => { if (!wssConsole) return res.status(500).send("Console WebSocket server not available."); api.start(wssConsole, req.body?.args); res.send(`Process start initiated.`); }, }, { method: "post", path: "/stop", install: ({ res, wssConsole }: StartStopRestartParams) => { if (!wssConsole) return res.status(500).send("Console WebSocket server not available."); api.stop(wssConsole); res.send(`Process stop initiated.`); }, }, { method: "post", path: "/restart", install: ({ req, res, wssConsole }: StartStopRestartParams) => { if (!wssConsole) return res.status(500).send("Console WebSocket server not available."); api.restart(wssConsole, req.body?.args); res.send(`Process restart initiated.`); }, }, { method: "get", path: "/console/status", install: ({ res }: ConsoleStatusParams) => { res.send(api.status()); }, }, ]; export function setupConsoleWS(wssConsole: WebSocketServer): void { wssConsole.on("connection", (ws: WebSocket) => { console.log("Console WebSocket client connected"); try { const logContent: string = fs.existsSync(LOG_FILE) ? fs.readFileSync(LOG_FILE, "utf8") : "\x1b[33mℹ️ No previous logs.\x1b[0m"; ws.send(logContent); } catch (err) { ws.send("\x1b[31mError reading past logs.\x1b[0m"); } ws.on("message", (rawMessage: RawData) => { try { const message = JSON.parse(rawMessage.toString()); if (message.type === 'STDIN_INPUT' && typeof message.payload === 'string') { if (processInstance && isRunning && isAwaitingInput && processInstance.stdin && processInstance.stdin.writable) { processInstance.stdin.write(message.payload + '\n'); isAwaitingInput = false; } else { api.broadcast(`\x1b[33m[SYSTEM-WARN] Process not running or not awaiting input.\x1b[0m`, wssConsole, true, false); } } } catch (e) { console.log(`Received non-command message from client: ${rawMessage.toString()}`); } }); ws.on("close", () => console.log("Console WebSocket client disconnected")); ws.on("error", (error: Error) => console.error("Console WebSocket client error:", error)); }); }