// @ts-check import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import fs from 'fs'; import simpleGit from 'simple-git'; import { Request, Response } from 'express'; import { WebSocketServer, WebSocket } from 'ws'; let shellProcess: ChildProcess | null = null; let currentCwd: string | null = null; let projectRootPath: string | null = null; const PROMPT_DELIMITER = '__EXOCORE_SHELL_PROMPT_BOUNDARY__\n'; const FORBIDDEN_COMMAND_PATTERNS: RegExp[] = [ /^cd\s+\.\.(?:\/\s*)?$/, /^cd\s+\.\.\/exocore-web\s*$/, /^cd\s+\.\.\/src\s*$/ ]; interface WsMessage { type: 'log' | 'prompt' | 'system'; data: string; } function isCommandForbidden(command: string): boolean { const trimmedCommand = command.trim(); return FORBIDDEN_COMMAND_PATTERNS.some((pattern) => pattern.test(trimmedCommand)); } function getFormattedCwdPrompt(cwd: string, root: string): string { const rootName = path.basename(root); if (cwd.startsWith(root)) { const relativePath = path.relative(root, cwd); return `/@${rootName}${relativePath ? `/${relativePath}` : ''}$ `; } return `${cwd}$ `; } function broadcastToShellWss(wssInstance: WebSocketServer, message: WsMessage): void { if (wssInstance && wssInstance.clients) { const messageString = JSON.stringify(message); wssInstance.clients.forEach((client: WebSocket) => { if (client.readyState === 1) { client.send(messageString); } }); } } export const modules = [ { method: 'post', path: '/shell/sent', install: async ({ req, res, Shellwss }: { req: Request, res: Response, Shellwss: WebSocketServer }): Promise => { const commandFromBody: string | null = req.body && typeof req.body.command === 'string' ? req.body.command : null; if (!commandFromBody) { res.status(400).send('No command provided.'); return; } const trimmedCommand = commandFromBody.trim(); if (isCommandForbidden(trimmedCommand)) { broadcastToShellWss(Shellwss, { type: 'log', data: '\x1b[31m Access Denied \x1b[0m\n' }); res.status(403).send('Command execution is restricted.'); return; } if (trimmedCommand.startsWith('exocore git clone ')) { const repoUrl: string = trimmedCommand.substring('exocore git clone '.length).trim(); if (!repoUrl) { broadcastToShellWss(Shellwss, { type: 'log', data: '\x1b[31mError: No repository URL provided...\x1b[0m\n' }); res.status(400).send('No repository URL provided.'); return; } const baseDirForPkg = path.resolve(__dirname, '../../src/pkg'); try { await fs.promises.mkdir(baseDirForPkg, { recursive: true }); const repoName = path.basename(repoUrl, '.git'); const clonePath = path.join(baseDirForPkg, repoName); broadcastToShellWss(Shellwss, { type: 'log', data: `\x1b[33mCloning ${repoUrl}...\x1b[0m\n` }); if (fs.existsSync(clonePath) && fs.readdirSync(clonePath).length > 0) { broadcastToShellWss(Shellwss, { type: 'log', data: `\x1b[33mDirectory ${clonePath} already exists. Skipping.\x1b[0m\n` }); res.status(200).send('Clone destination already exists.'); return; } const git = simpleGit(); await git.clone(repoUrl, clonePath); broadcastToShellWss(Shellwss, { type: 'log', data: `\x1b[32mSuccessfully cloned ${repoUrl}\x1b[0m\n` }); res.send('Clone command executed successfully.'); } catch (error: any) { const errMsg = error instanceof Error ? error.message : String(error); broadcastToShellWss(Shellwss, { type: 'log', data: `\x1b[31mFailed to clone: ${errMsg}\x1b[0m\n` }); res.status(500).send('Clone command failed.'); } return; } if (!shellProcess) { let projectPath: string; let customPkgPathForShellEnv: string | undefined; try { const configJsonPath = path.resolve(__dirname, '../config.json'); const configData = JSON.parse(fs.readFileSync(configJsonPath, 'utf-8')); projectPath = path.resolve(path.dirname(configJsonPath), configData.project); } catch (e) { projectPath = path.resolve(__dirname, '..'); } customPkgPathForShellEnv = path.resolve(__dirname, '../../src/pkg'); if (!fs.existsSync(customPkgPathForShellEnv) || !fs.statSync(customPkgPathForShellEnv).isDirectory()) { customPkgPathForShellEnv = undefined; } projectRootPath = projectPath; currentCwd = projectPath; const currentEnv: NodeJS.ProcessEnv = { ...process.env }; let effectivePath: string | undefined = currentEnv.PATH; if (customPkgPathForShellEnv) { effectivePath = `${customPkgPathForShellEnv}:${effectivePath || ''}`; } const shellEnv: NodeJS.ProcessEnv = { ...currentEnv, FORCE_COLOR: '1', NPM_CONFIG_COLOR: 'always', TERM: 'xterm-256color', LANG: 'en_US.UTF-8', PATH: effectivePath, }; shellProcess = spawn('bash', { cwd: projectPath, shell: true, env: shellEnv }); let stdoutBuffer = ''; const handleShellOutput = (data: Buffer | string) => { stdoutBuffer += data.toString(); while (stdoutBuffer.includes(PROMPT_DELIMITER)) { const boundaryIndex = stdoutBuffer.indexOf(PROMPT_DELIMITER); const chunk = stdoutBuffer.substring(0, boundaryIndex); stdoutBuffer = stdoutBuffer.substring(boundaryIndex + PROMPT_DELIMITER.length); const lines = chunk.trim().split('\n'); const newCwd = lines.pop()?.trim(); const commandOutput = lines.join('\n'); if (commandOutput) { broadcastToShellWss(Shellwss, { type: 'log', data: commandOutput + '\n' }); } if (newCwd && projectRootPath && fs.existsSync(newCwd)) { currentCwd = newCwd; const newPrompt = getFormattedCwdPrompt(currentCwd, projectRootPath); broadcastToShellWss(Shellwss, { type: 'prompt', data: newPrompt }); } } }; shellProcess.stdout?.on('data', handleShellOutput); shellProcess.stderr?.on('data', (data) => broadcastToShellWss(Shellwss, { type: 'log', data: `\x1b[31m${data.toString()}\x1b[0m` })); shellProcess.on('close', (code) => { broadcastToShellWss(Shellwss, { type: 'system', data: `\x1b[33mShell exited (code: ${code}).\x1b[0m\n` }); shellProcess = null; currentCwd = null; projectRootPath = null; }); shellProcess.on('error', (err) => { broadcastToShellWss(Shellwss, { type: 'log', data: `\x1b[31mFailed to start shell: ${err.message}\x1b[0m\n` }); shellProcess = null; }); await new Promise(resolve => setTimeout(resolve, 100)); } if (shellProcess && shellProcess.stdin?.writable) { shellProcess.stdin.write(trimmedCommand + '\n'); shellProcess.stdin.write(`pwd && echo "${PROMPT_DELIMITER.trim()}"\n`); res.send('Command sent to shell.'); } else { broadcastToShellWss(Shellwss, { type: 'log', data: '\x1b[31mCannot send command: Shell is not ready.\x1b[0m\n' }); res.status(503).send('Shell process not available.'); } }, }, { method: 'post', path: '/shell/kill', install: ({ res, Shellwss }: { res: Response, Shellwss: WebSocketServer }) => { if (shellProcess) { shellProcess.kill(); broadcastToShellWss(Shellwss, { type: 'system', data: '\x1b[33mKill signal sent.\x1b[0m\n' }); res.send('Kill signal sent.'); } else { broadcastToShellWss(Shellwss, { type: 'system', data: '\x1b[33mNo active shell to kill.\x1b[0m\n' }); res.status(404).send('No active shell process.'); } }, }, ]; export function setupShellWS(Shellwss: WebSocketServer): void { Shellwss.on('connection', (ws: WebSocket) => { ws.send(JSON.stringify({ type: 'system', data: '\x1b[32mWelcome to the interactive shell!\x1b[0m\n' })); if (shellProcess && currentCwd && projectRootPath) { const prompt = getFormattedCwdPrompt(currentCwd, projectRootPath); ws.send(JSON.stringify({ type: 'prompt', data: prompt })); } else { ws.send(JSON.stringify({ type: 'system', data: '\x1b[33mShell not running. Send command to start.\x1b[0m\n' })); } ws.on('message', (message: Buffer | string) => { ws.send(JSON.stringify({ type: 'system', data: '\x1b[33mPlease send commands through the terminal input, not directly via WebSocket message.\x1b[0m\n' })); }); }); } export function stopShellProcessOnExit(): void { if (shellProcess) { shellProcess.kill('SIGTERM'); } }