From 212708b464c2bdbdd9670c00804623dca0729d2c Mon Sep 17 00:00:00 2001 From: Agando Date: Wed, 27 May 2026 06:00:43 +0200 Subject: auto: 2026-05-27_06:00 changes --- modules/ext/shell.js | 517 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 modules/ext/shell.js (limited to 'modules/ext/shell.js') diff --git a/modules/ext/shell.js b/modules/ext/shell.js new file mode 100644 index 0000000..d5e6a43 --- /dev/null +++ b/modules/ext/shell.js @@ -0,0 +1,517 @@ +class ShellModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._vars = { + HOME: '/', + USER: 'darkbot', + SHELL: 'webx11-shell', + TERM: 'xterm-256color', + PWD: '/' + }; + this._scriptDepth = 0; + this._maxScriptDepth = 3; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'export': 'export = — Set a shell variable', + 'unset': 'unset — Remove a shell variable', + 'script': 'script — Run a shell script from VFS', + '$': '$ — Execute with shell parsing (default: auto-detected)', + 'set': 'set — List all shell variables', + }; + } + + registerCommands() { + this.commands['export'] = (args) => this._cmdExport(args); + this.commands['unset'] = (args) => this._cmdUnset(args); + this.commands['script'] = (args) => this._cmdScript(args); + this.commands['set'] = () => this._cmdSet(); + } + + // ── Hook into terminal.executeCommand ── + + init() { + // Wrap executeCommand to add shell parsing + const origExecute = this.terminal.executeCommand.bind(this.terminal); + const self = this; + this.terminal.executeCommand = async function(rawInput) { + if (!rawInput.trim()) return; + this.markDirty(); + this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt'); + // Substitute variables in the raw input + const substituted = self._substituteVars(rawInput); + // Split on ; first (sequence) + const seqParts = self._splitSequence(substituted); + for (const seqPart of seqParts) { + if (!seqPart.trim()) continue; + // Split on && and || + const chainParts = self._splitChain(seqPart.trim()); + let lastOk = true; + for (const chainPart of chainParts) { + if (!chainPart.trim()) continue; + const { cmd: chainCmd, operator } = chainPart; + if (operator === '&&' && !lastOk) continue; + if (operator === '||' && lastOk) continue; + // Check for redirects + const redirectInfo = self._extractRedirects(chainCmd); + if (redirectInfo.hasRedirect) { + // Run, capture, write to file + await self._executeWithRedirect(redirectInfo); + lastOk = redirectInfo.exitOk; + } else { + // Normal or piped command + const pipeParts = self.terminal.splitPipe(chainCmd); + if (pipeParts.length > 1) { + await self.terminal.executePipeChain(pipeParts); + } else { + // Single command + const parts = chainCmd.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + await this.security.checkAndRun(chainCmd); + } + lastOk = true; + } + } + } + }; + this.terminal._shellModule = this; + + // Modify pipe chain to read _pipeInput for commands that support it + const origPipeChain = this.terminal.executePipeChain.bind(this.terminal); + this.terminal.executePipeChain = async function(pipeParts) { + // Write pipe input from previous segment as temp file for next segment + const shellMod = this._shellModule; + let pipeInput = null; + for (let i = 0; i < pipeParts.length; i++) { + const cmdText = pipeParts[i]; + const parts = cmdText.split(/\s+/); + const cmd = parts[0].toLowerCase(); + let args = parts.slice(1); + this._pipeInput = pipeInput; + + if (pipeInput !== null) { + // Write pipe input as temp file so file-reading commands can use it + this.fs.writeFile('__pipebuf__', pipeInput); + // Inject __pipebuf__ as file arg if command is pipe-aware (reads files for input) + if (shellMod && shellMod._isPipeAware(cmd)) { + // Don't inject if command already has enough file args + if (!args.some(a => !a.startsWith('-') && a !== '|' && a !== '>')) { + args = args.concat('__pipebuf__'); + } + } + } + + if (i < pipeParts.length - 1) { + pipeInput = await this.capturePipeOutput(cmd, args); + } else { + await this.dispatchCommand(cmd, args); + } + + // Clean up temp file + if (this.fs.readFile('__pipebuf__') !== null) { + this.fs.root.content['__pipebuf__'] = undefined; + delete this.fs.root.content['__pipebuf__']; + } + } + this._pipeInput = null; + }; + } + + // ── Tokenizer helpers ── + + _splitSequence(input) { + // Split on ; (not inside quotes) + const parts = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + for (const ch of input) { + if ((ch === '"' || ch === "'") && !inQuote) { + inQuote = true; quoteChar = ch; current += ch; + } else if (ch === quoteChar && inQuote) { + inQuote = false; quoteChar = ''; current += ch; + } else if (ch === ';' && !inQuote) { + parts.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + parts.push(current.trim()); + return parts; + } + + _splitChain(input) { + // Split on && and ||, preserving operator info + const parts = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + let i = 0; + while (i < input.length) { + const ch = input[i]; + if ((ch === '"' || ch === "'") && !inQuote) { + inQuote = true; quoteChar = ch; current += ch; i++; + } else if (ch === quoteChar && inQuote) { + inQuote = false; quoteChar = ''; current += ch; i++; + } else if (!inQuote && input.substring(i, i+2) === '&&') { + parts.push({ cmd: current.trim(), operator: '&&' }); + current = ''; i += 2; + } else if (!inQuote && input.substring(i, i+2) === '||') { + parts.push({ cmd: current.trim(), operator: '||' }); + current = ''; i += 2; + } else { + current += ch; i++; + } + } + parts.push({ cmd: current.trim(), operator: null }); + return parts; + } + + _extractRedirects(input) { + // Find >, >>, < outside quotes + let cmd = ''; + let hasRedirect = false; + let redirectOp = ''; + let redirectFile = ''; + let append = false; + let inQuote = false, quoteChar = ''; + let i = 0; + const parts = []; + let current = ''; + while (i < input.length) { + const ch = input[i]; + if ((ch === '"' || ch === "'") && !inQuote) { + inQuote = true; quoteChar = ch; current += ch; i++; + } else if (ch === quoteChar && inQuote) { + inQuote = false; quoteChar = ''; current += ch; i++; + } else if (!inQuote) { + if (input.substring(i, i+2) === '>>') { + if (current.trim()) parts.push(current.trim()); + hasRedirect = true; + redirectOp = '>>'; + append = true; + i += 2; + // Read until next redirect or end + let file = ''; + while (i < input.length && input[i] === ' ') i++; + while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { + file += input[i]; i++; + } + redirectFile = file.trim(); + if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; + break; + } else if (input.substring(i, i+1) === '>') { + if (current.trim()) parts.push(current.trim()); + hasRedirect = true; + redirectOp = '>'; + append = false; + i += 1; + let file = ''; + while (i < input.length && input[i] === ' ') i++; + while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { + file += input[i]; i++; + } + redirectFile = file.trim(); + if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; + break; + } else if (input.substring(i, i+1) === '<') { + if (current.trim()) parts.push(current.trim()); + hasRedirect = true; + redirectOp = '<'; + i += 1; + let file = ''; + while (i < input.length && input[i] === ' ') i++; + while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { + file += input[i]; i++; + } + redirectFile = file.trim(); + if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; + break; + } else { + current += ch; i++; + } + } else { + current += ch; i++; + } + } + if (current.trim()) parts.push(current.trim()); + + const cmdText = parts.join(' '); + return { + hasRedirect, + redirectOp, + redirectFile, + append, + cmdText, + exitOk: true + }; + } + + async _executeWithRedirect(redirectInfo) { + const { redirectOp, redirectFile, append, cmdText } = redirectInfo; + const fs = this.terminal.fs; + + if (redirectOp === '<') { + // Read file content as stdin + const inputContent = fs.readFile(redirectFile); + if (inputContent === null) { + this.terminal.print(`shell: ${redirectFile}: No such file`, 'error'); + return; + } + this.terminal._pipeInput = inputContent; + // Execute the command with pipe input available + const parts = cmdText.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // If command reads files, inject pipe content as temp file + if (this._isPipeAware(cmd)) { + fs.writeFile('__pipebuf__', inputContent); + const newArgs = [...args]; + if (!newArgs.some(a => !a.startsWith('-'))) { + newArgs.push('__pipebuf__'); + } + await this.terminal.dispatchCommand(cmd, newArgs); + if (fs.readFile('__pipebuf__') !== null) { + delete fs.root.content['__pipebuf__']; + } + } else { + await this.terminal.dispatchCommand(cmd, args); + } + this.terminal._pipeInput = null; + return; + } + + // > or >> — capture output, write to file + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + + // Check for pipes within the command before redirect + const pipeParts = this.terminal.splitPipe(cmdText); + if (pipeParts.length > 1) { + // Execute pipe chain manually to capture all output + let pipeInput = null; + for (let i = 0; i < pipeParts.length; i++) { + const pText = pipeParts[i]; + const pParts = pText.split(/\s+/); + const pCmd = pParts[0].toLowerCase(); + const pArgs = pParts.slice(1); + if (pipeInput !== null) { + fs.writeFile('__pipebuf__', pipeInput); + } + if (i < pipeParts.length - 1) { + const captPrint = this.terminal.print.bind(this.terminal); + let capt = ''; + this.terminal.print = (t, ty) => { capt += t + '\n'; }; + await this.terminal.dispatchCommand(pCmd, pArgs); + this.terminal.print = captPrint; + pipeInput = capt.trim(); + } else { + await this.terminal.dispatchCommand(pCmd, pArgs); + } + if (fs.readFile('__pipebuf__') !== null) { + delete fs.root.content['__pipebuf__']; + } + } + } else { + const parts = cmdText.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + await this.terminal.dispatchCommand(cmd, args); + } + + this.terminal.print = originalPrint; + const captured = output.trimEnd(); + + if (captured) { + if (append) { + const existing = fs.readFile(redirectFile) || ''; + fs.writeFile(redirectFile, existing + (existing ? '\n' : '') + captured); + } else { + fs.writeFile(redirectFile, captured); + } + this.terminal.print(`Redirected ${captured.split('\n').length} line(s) to ${redirectFile}`, 'success'); + } else { + // Write empty file + if (!append) { + fs.writeFile(redirectFile, ''); + this.terminal.print(`Redirected (empty) to ${redirectFile}`, 'success'); + } + } + } + + _isPipeAware(cmd) { + // Commands that read from files and should use pipe buffer + return ['grep', 'head', 'tail', 'sort', 'wc', 'cut', 'uniq', 'expand'].includes(cmd); + } + + // ── Variable substitution ── + + _substituteVars(input) { + // Replace $VAR and ${VAR} (not inside single quotes) + let result = ''; + let inSingle = false; + let i = 0; + while (i < input.length) { + const ch = input[i]; + if (ch === "'") { + inSingle = !inSingle; + result += ch; + i++; + } else if (ch === '$' && !inSingle && i + 1 < input.length) { + // Check for $( + if (input[i+1] === '(') { + result += ch; + i++; + continue; + } + // Check for ${VAR} + if (input[i+1] === '{') { + let end = input.indexOf('}', i); + if (end !== -1) { + const varName = input.substring(i+2, end); + const val = this._getVar(varName); + result += val !== undefined ? val : '$' + varName; + i = end + 1; + continue; + } + } + // Check for $VAR (alphanumeric/underscore) + let j = i + 1; + if (j < input.length && /[A-Za-z_]/.test(input[j])) { + while (j < input.length && /\w/.test(input[j])) j++; + const varName = input.substring(i+1, j); + const val = this._getVar(varName); + result += val !== undefined ? val : ''; + i = j; + continue; + } + // $ followed by digit — not a var + result += ch; + i++; + } else { + result += ch; + i++; + } + } + return result; + } + + _getVar(name) { + if (name === 'PWD') return '/' + this.terminal.fs.currentPath.join('/'); + return this._vars[name] !== undefined ? this._vars[name] : undefined; + } + + _setVar(name, value) { + this._vars[name] = value; + } + + // ── Commands ── + + _cmdExport(args) { + if (!args[0] || !args[0].includes('=')) { + return this.terminal.print('Usage: export =', 'warning'); + } + const eqIdx = args[0].indexOf('='); + const name = args[0].substring(0, eqIdx); + let value = args[0].substring(eqIdx + 1); + // Support quoted values: export FOO="hello world" + if (args.length > 1) { + const rest = args.slice(1).join(' '); + if (value.endsWith('"') || value.endsWith("'")) { + // First part ended quote — fine + } else if (value.startsWith('"') || value.startsWith("'")) { + value = value + (rest ? ' ' + rest : ''); + } + } + // Strip surrounding quotes + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.substring(1, value.length - 1); + } + this._setVar(name, value); + this.terminal.print(`Shell ${name}=${value}`, 'success'); + } + + _cmdUnset(args) { + if (!args[0]) return this.terminal.print('Usage: unset ', 'warning'); + delete this._vars[args[0]]; + this.terminal.print(`Shell var removed: ${args[0]}`, 'info'); + } + + _cmdSet() { + const fs = this.terminal.fs; + this.terminal.print('Shell variables:', 'info'); + for (const [k, v] of Object.entries(this._vars)) { + this.terminal.print(`${k}=${v}`, 'info'); + } + this.terminal.print(`PWD=/${fs.currentPath.join('/')}`, 'info'); + } + + async _cmdScript(args) { + if (!args[0]) return this.terminal.print('Usage: script ', 'warning'); + if (this._scriptDepth >= this._maxScriptDepth) { + return this.terminal.print(`script: max recursion depth (${this._maxScriptDepth}) reached`, 'error'); + } + const path = args[0]; + const content = this.terminal.fs.readFile(path); + if (content === null) return this.terminal.print(`script: ${path}: No such file`, 'error'); + this._scriptDepth++; + this.terminal.print(`Running script: ${path}`, 'info'); + const lines = content.split('\n'); + let lineNum = 0; + for (const rawLine of lines) { + lineNum++; + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + // Run each line through the shell (by calling executeCommand directly) + // We need to manually call the wrapped executeCommand + this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt'); + // Parse inline: handle pipe, redirect, chain, sequence + const substituted = this._substituteVars(line); + const seqParts = this._splitSequence(substituted); + for (const seqPart of seqParts) { + if (!seqPart.trim()) continue; + const chainParts = this._splitChain(seqPart.trim()); + let lastOk = true; + for (const chainPart of chainParts) { + if (!chainPart.cmd.trim()) continue; + if (chainPart.operator === '&&' && !lastOk) continue; + if (chainPart.operator === '||' && lastOk) continue; + const redirectInfo = this._extractRedirects(chainPart.cmd); + if (redirectInfo.hasRedirect) { + await this._executeWithRedirect(redirectInfo); + lastOk = redirectInfo.exitOk; + } else { + const pipeParts = this.terminal.splitPipe(chainPart.cmd); + if (pipeParts.length > 1) { + await this.terminal.executePipeChain(pipeParts); + } else { + const parts = chainPart.cmd.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // Check if it's our built-in command + if (this.commands[cmd]) { + this.terminal.markDirty(); + await this.commands[cmd](args); + } else { + await this.terminal.security.checkAndRun(chainPart.cmd); + } + } + lastOk = true; + } + } + } + } + this._scriptDepth--; + this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success'); + } +} -- cgit v1.2.3