| |
|
|
| 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<void> => { |
| 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'); |
| } |
| } |