diff options
Diffstat (limited to 'modules/ext/critical-ext-bundle.js')
| -rw-r--r-- | modules/ext/critical-ext-bundle.js | 362 |
1 files changed, 300 insertions, 62 deletions
diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js index d1a2b83..fb623fd 100644 --- a/modules/ext/critical-ext-bundle.js +++ b/modules/ext/critical-ext-bundle.js @@ -175,8 +175,15 @@ class ShellModule { // ── Hook into terminal.executeCommand ── init() { - // Guard: prevent double-wrapping if init is called multiple times + // Guard: prevent double-wrapping if init is called multiple times, and adopt the wrapper + // if another instance already installed one (shell.js used to be loaded twice — once in + // critical-ext-bundle.js, once from main.html's extension list — and the two copies could + // disagree about their version). if (this._initialized) return; + if (this.terminal && this.terminal._shellModule && this.terminal._shellModule !== this) { + this._initialized = true; + return; + } this._initialized = true; // Wrap executeCommand to add shell parsing — only for compound commands @@ -226,9 +233,10 @@ class ShellModule { // Check for redirects const redirectInfo = self._extractRedirects(chainCmd); if (redirectInfo.hasRedirect) { - // Run, capture, write to file - await self._executeWithRedirect(redirectInfo); - lastOk = redirectInfo.exitOk; + // Same capture-and-write path the script runner uses. _executeWithRedirect + // handed `>` to builtins, so `echo hi > /f` printed the operator instead of + // writing the file. + lastOk = await self._runCommand(chainCmd, { $: 0 }, { inScript: false }); } else { // Normal or piped command const pipeParts = this.splitPipe(chainCmd); @@ -238,7 +246,11 @@ class ShellModule { // Single command — check for shell built-ins first const parts = chainCmd.split(/\s+/); const cmd = parts[0].toLowerCase(); - const args = parts.slice(1); + // Globs expand at the prompt too — the script runner did it and the + // interactive path did not, so `ls *.txt` printed (empty) but the same + // line inside a script worked. + const args = self._expandGlobs(parts.slice(1), (this.fs && this.fs.currentPath) || []); + const expandedCmd = [cmd].concat(args).join(' '); if (self.commands[cmd]) { try { this.markDirty(); @@ -250,7 +262,7 @@ class ShellModule { } } else { try { - await this.security.checkAndRun(chainCmd); + await this.security.checkAndRun(expandedCmd); lastOk = true; } catch(e) { this.print('Error: ' + e.message, 'error'); @@ -579,8 +591,10 @@ class ShellModule { } _isPipeAware(cmd) { - // Commands that read from files and should use pipe buffer - return ['grep', 'head', 'tail', 'sort', 'wc', 'cut', 'uniq', 'expand'].includes(cmd); + // Commands that read from files and should use the pipe buffer file. wc is NOT here any + // more: it reads terminal._pipeInput natively (v4.6.111), and injecting __pipebuf__ gave + // `3 __pipebuf__` instead of a bare count. + return ['grep', 'head', 'tail', 'sort', 'cut', 'uniq', 'expand'].includes(cmd); } // ── Variable substitution ── @@ -697,68 +711,292 @@ class ShellModule { this._scriptDepth++; try { 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; - this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt'); - 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 a shell built-in command - if (this.commands[cmd]) { - try { - this.terminal.markDirty(); - await this.commands[cmd](args); - lastOk = true; - } catch(e) { - this.terminal.print('Error: ' + e.message, 'error'); - lastOk = false; - } - } else { - try { - await this.terminal.security.checkAndRun(chainPart.cmd); - lastOk = true; - } catch(e) { - this.terminal.print('Error: ' + e.message, 'error'); - lastOk = false; - } - } - } - } - } - } + const env = Object.assign({ $: 0 }, this._scriptEnv || {}); + this._inScript = true; + let result = { ran: 0 }; + try { + const tree = this._parseBlocks(content.split('\n')); + result = await this._runInstructions(tree, env, { path, inScript: true }); + } catch (e) { + // A parse error (unclosed block, bad for-header) must read as a script error, not + // as a crashed terminal. + this.terminal.print(`Script ${path} failed: ${e.message}`, 'error'); + result = { ran: 0 }; + } finally { + this._inScript = false; } - this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success'); + this._scriptEnv = env; + this.terminal.print(`Script ${path} completed (${result.ran} statements, exit ${env.$})`, 'success'); } finally { // Always decrement — even on error or thrown exception this._scriptDepth--; } } + // Parse a script into an instruction tree. Line-by-line execution cannot express a loop body, + // which is why `for … done` used to be fed to the AI one line at a time. + _parseBlocks(lines) { + const root = { body: [] }; + const stack = [root]; + const cur = () => stack[stack.length - 1]; + const push = (node) => { + const top = cur(); + if (top.op === 'if') { (top.inElse ? top.else : top.then).push(node); } + else { top.body.push(node); } + }; + // `if [ -f /x ]; then echo y; fi` on one line is common enough to support: split atoms on + // ';' when the line carries a block keyword, and keep quoted text intact otherwise. + const atoms = []; + lines.forEach((raw) => { + const l = String(raw).replace(/\r$/, ''); + if (l.includes(';') && /\b(then|do|done|fi|else)\b/.test(l)) l.split(';').forEach(p => atoms.push(p)); + else atoms.push(l); + }); + atoms.forEach((raw, idx) => { + const line = String(raw).replace(/\r$/, '').trim(); + const lineNo = idx + 1; + if (!line || line.startsWith('#')) return; + const head = line.split(/\s+/)[0]; + if (head === 'for') { + const m = line.match(/^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.*)$/); + if (!m) throw new Error('line ' + lineNo + ": expected 'for <var> in <items>'"); + const node = { op: 'for', line: lineNo, var: m[1], items: m[2].trim().split(/\s+/).filter(Boolean), body: [] }; + push(node); stack.push(node); return; + } + if (head === 'while') { + const node = { op: 'while', line: lineNo, test: line.slice(5).trim(), body: [] }; + push(node); stack.push(node); return; + } + if (head === 'if') { + const node = { op: 'if', line: lineNo, test: line.slice(2).trim(), then: [], else: [], inElse: false }; + push(node); stack.push(node); return; + } + if (head === 'then' || head === 'do') { + // `then echo y` on the same atom still means "run echo y" + const rest = line.slice(head.length).trim(); + if (rest) push({ op: 'simple', line: lineNo, text: rest }); + return; + } + if (head === 'else') { + const top = cur(); + if (top.op !== 'if') throw new Error('line ' + lineNo + ": 'else' without 'if'"); + top.inElse = true; + const rest = line.slice(4).trim(); + if (rest) push({ op: 'simple', line: lineNo, text: rest }); + return; + } + if (head === 'done' || head === 'fi') { + const top = stack.pop(); + const want = !top ? null : (top.op === 'if' ? 'fi' : 'done'); + if (!want) throw new Error('line ' + lineNo + ": '" + head + "' without an open block"); + if (head !== want) throw new Error('line ' + lineNo + ": expected '" + want + "' to close '" + top.op + "' from line " + top.line); + return; + } + push({ op: 'simple', line: lineNo, text: line }); + }); + if (stack.length !== 1) { + const open = stack[stack.length - 1]; + throw new Error("unclosed '" + open.op + "' from line " + open.line + " — expected '" + (open.op === 'if' ? 'fi' : 'done') + "'"); + } + const fix = (nodes) => nodes.map(n => { + if (n.op === 'if') { n.body = n.then; n.elseBody = n.else; } + if (n.body && (n.op === 'for' || n.op === 'while')) fix(n.body); + if (n.body && n.op === 'if') fix(n.body); + if (n.elseBody) fix(n.elseBody); + return n; + }); + return fix(root.body); + } + + // Same rules as _substituteVars, but against an explicit env so a loop body sees `$i`, and + // `$?` carries the previous statement's exit status. + _expandVars(input, env) { + const src = String(input); + let out = '', inSingle = false, inDouble = false; + for (let i = 0; i < src.length; i++) { + const ch = src[i]; + if (ch === "'" && !inDouble) { inSingle = !inSingle; out += ch; continue; } + if (ch === '"' && !inSingle) { inDouble = !inDouble; out += ch; continue; } + if (ch !== '$' || inSingle) { out += ch; continue; } + const next = src[i + 1]; + if (next === '?') { out += String(env.$ == null ? 0 : env.$); i++; continue; } + let name = ''; + if (next === '{') { + const end = src.indexOf('}', i + 2); + if (end === -1) { out += ch; continue; } + name = src.slice(i + 2, end); i = end; + } else { + const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i + 1)); + if (!m) { out += ch; continue; } + name = m[0]; i += name.length; + } + out += (env[name] == null ? '' : String(env[name])); + } + return out; + } + + // Wildcards only expand for unquoted arguments, and only against the VFS — `ls *.txt` used to + // return an empty list because the literal pattern was handed to ls. + _expandGlobs(args, cwdSegments) { + const globToRe = (pat) => new RegExp('^' + pat.replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]') + '$'); + const dirOf = (p) => { + const segs = String(p).split('/').filter(Boolean); + segs.pop(); + return segs; + }; + return args.flatMap((arg) => { + const a = String(arg); + if (/^['"]/.test(a) || !/[*?]/.test(a)) return [a]; + const segs = a.split('/').filter(Boolean); + const namePat = segs.pop() || '*'; + const rawDir = a.startsWith('/') ? segs : cwdSegments.concat(segs); + // cwd segments may arrive as ['work'] or ['/work'] — normalise both + const dirPath = '/' + rawDir.map(s => String(s).split('/').filter(Boolean).join('/')) + .filter(Boolean).join('/'); + let entries = []; + try { entries = this.terminal.fs.ls(dirPath) || []; } catch (e) { entries = []; } + const re = globToRe(namePat); + const hits = entries.filter(e => re.test(e.name)).map(e => (dirPath === '/' ? '' : dirPath) + '/' + e.name); + return hits.length ? hits : [a]; + }); + } + + + // Evaluate the only condition syntaxes the runner supports: test -f/-d/-e/-z/-n, `a = b`, + // and the bracket spelling of the same. Anything else is false, never a crash. + _evalTest(expr, env) { + const t = this._expandVars(String(expr == null ? '' : expr), env || {}) + .trim().replace(/^\[/, '').replace(/\]$/, '').trim(); + if (!t) return false; + const fs = this.terminal.fs; + const parts = t.split(/\s+/); + const p = parts[1]; + switch (parts[0]) { + case '-f': return typeof fs.readFile(p) === 'string'; + case '-d': return !!fs.readDir(p); + case '-e': return typeof fs.readFile(p) === 'string' || !!fs.readDir(p); + case '-z': return !p; + case '-n': return !!p; + case '!': return !this._evalTest(parts.slice(1).join(' '), env); + } + if (parts.length === 3 && (parts[1] === '=' || parts[1] === '==')) return parts[0] === parts[2]; + if (parts.length === 3 && parts[1] === '!=') return parts[0] !== parts[2]; + return false; + } + + async _runInstructions(nodes, env, ctx) { + let ran = 0; + for (const node of nodes) { + if (node.op === 'for') { + for (const item of node.items) { + env[node.var] = this._expandVars(item, env); + const r = await this._runInstructions(node.body, env, ctx); + ran += r.ran + 1; + } + continue; + } + if (node.op === 'while') { + let guard = 0; + const cap = this._maxLoopIterations || 1000; + while (guard++ < cap && this._evalTest(node.test, env)) { + const r = await this._runInstructions(node.body, env, ctx); + ran += r.ran + 1; + } + if (guard > cap) this.terminal.print('script: while loop hit the iteration limit (' + cap + ')', 'warning'); + continue; + } + if (node.op === 'if') { + const take = this._evalTest(node.test, env); + env.$ = take ? 0 : 1; + const r = await this._runInstructions(take ? node.body : (node.elseBody || []), env, ctx); + ran += r.ran + 1; + continue; + } + const r = await this._runSimple(this._expandVars(node.text, env), env, Object.assign({}, ctx, { line: node.line })); + ran += r.ran; + } + return { ran }; + } + + async _runSimple(text, env, ctx) { + const seqParts = this._splitSequence(text); + let lastOk = true; + for (const seqPart of seqParts) { + if (!seqPart.trim()) continue; + lastOk = true; + for (const chainPart of this._splitChain(seqPart.trim())) { + if (!chainPart.cmd.trim()) continue; + if (chainPart.operator === '&&' && !lastOk) continue; + if (chainPart.operator === '||' && lastOk) continue; + lastOk = await this._runCommand(chainPart.cmd, env, ctx); + } + } + env.$ = lastOk ? 0 : 1; + return { ran: 1, ok: lastOk }; + } + + async _runCommand(cmdText, env, ctx) { + const redirectInfo = this._extractRedirects(cmdText); + if (redirectInfo.hasRedirect) { + const cap = { output: '', onChunk: null }; + const stack = this.terminal._printCaptureStack || (this.terminal._printCaptureStack = []); + stack.push(cap); + let ok = true; + try { ok = await this._runCommand(redirectInfo.cmdText, env, ctx); } + catch (e) { this.terminal.print('Error: ' + e.message, 'error'); ok = false; } + finally { stack.pop(); } + const target = redirectInfo.redirectFile; + const prev = redirectInfo.append ? (this.terminal.fs.readFile(target) || '') : ''; + const wrote = this.terminal.fs.writeFile(target, prev + cap.output); + if (!wrote) { this.terminal.print('script: cannot write ' + target, 'error'); return false; } + return ok; + } + const pipeParts = this.terminal.splitPipe ? this.terminal.splitPipe(cmdText) : [cmdText]; + if (pipeParts.length > 1) { + await this.terminal.executePipeChain(pipeParts); + return true; + } + return this._dispatchOne(cmdText, env, ctx); + } + + async _dispatchOne(cmdText, env, ctx) { + const parts = String(cmdText).trim().split(/\s+/); + const cmd = (parts[0] || '').toLowerCase(); + const cwd = (this.terminal.fs && this.terminal.fs.currentPath) || []; + const args = this._expandGlobs(parts.slice(1), cwd); + if (this.commands[cmd]) { + try { this.terminal.markDirty(); await this.commands[cmd](args); return true; } + catch (e) { this.terminal.print('Error: ' + e.message, 'error'); return false; } + } + const known = (this.terminal.moduleManager && this.terminal.moduleManager.getAllCommands + ? this.terminal.moduleManager.getAllCommands() : {})[cmd]; + if (ctx && ctx.inScript && !known) { + // A script must never answer an unknown command by calling the AI. + this.terminal.print('script:' + ctx.path + ':' + (ctx.line || '?') + ': ' + cmd + ': command not found', 'error'); + return false; + } + // Dispatch the EXPANDED argument list — passing cmdText here silently dropped the glob + // results, so `ls /g*.txt` still reached the fs as a literal pattern. + const rebuilt = [cmd].concat(args).join(' '); + try { await this.terminal.security.checkAndRun(rebuilt); return true; } + catch (e) { this.terminal.print('Error: ' + e.message, 'error'); return false; } + } + } if (typeof window.webx11 !== 'undefined' && window.webx11) { - webx11.moduleManager.registerModule(new ShellModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/shell.js'); + // Register with a cache-busting query: an unversioned URL let the browser keep serving a + // pre-v4.6.111 shell.js after the bundle had already shipped the new runner. + const _mm = window.webx11.moduleManager; + const _ver = (_mm && (_mm.LATEST_VER || _mm.latestVer)) || ('t' + Date.now()); + const _inst = new ShellModule(); + webx11.moduleManager.registerModule(_inst, + 'https://webx11.duckdns.org/spaces/newos4/modules/ext/shell.js?v=' + _ver); + // registerModule() does not call init(); only loadModule() does. A module registered from the + // boot bundle therefore never installed its executeCommand wrapper, so prompt-level globbing + // and redirects silently did nothing. Init it here, once. + if (typeof _inst.init === 'function' && !_inst._initialized) { + try { _inst.init(); } catch (e) { console.warn('ShellModule init failed:', e && e.message); } + } } /* === alias.js === */ |