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