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() { // Guard: prevent double-wrapping if init is called multiple times if (this._initialized) return; this._initialized = true; // Wrap executeCommand to add shell parsing — only for compound commands const origExecute = this.terminal.executeCommand.bind(this.terminal); const self = this; this.terminal.executeCommand = async function(rawInput) { if (!rawInput.trim()) return; this.markDirty(); this.printUserPrompt(rawInput); // For simple single-word commands without special chars, skip shell parsing if (/^[a-zA-Z0-9._\/-]+$/.test(rawInput.trim()) && !rawInput.includes(' ') && !rawInput.includes('$')) { try { return await this.security.checkAndRun(rawInput); } catch(e) { this.print('Error: ' + e.message, 'error'); return; } } // 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.cmd.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); try { await this.security.checkAndRun(chainCmd); } catch(e) { this.terminal && this.terminal.print ? this.terminal.print('Error: ' + e.message, 'error') : console.error(e); } } 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'); } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new ShellModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/shell.js'); }