| | |
| |
|
| | 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<RouteHandlerParamsSuperset>) => void; |
| | } |
| |
|
| | type StartStopRestartParams = Pick<RouteHandlerParamsSuperset, 'req' | 'res' | 'wssConsole'>; |
| | type ConsoleStatusParams = Pick<RouteHandlerParamsSuperset, 'res'>; |
| |
|
| | 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)); |
| | }); |
| | } |
| |
|