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