| |
|
|
| 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)); |
| }); |
| } |
|
|