diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-27 16:23:21 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-27 16:23:21 +0100 |
| commit | cd71cc814f0d51c2d71fca4231690228a1e1da83 (patch) | |
| tree | c123b3695bc34c6bba87201f448f160872efb33e | |
| parent | 7e4e30a1790ced2b745a73e9c83e41d6e224bac6 (diff) | |
feat(newos4): the script runner runs scripts — control flow, variables, globs, redirects, stdin
MAINT-021 and MAINT-017, executed from .hermes/plans/2026-10-27_145500-newos4-script-runner.md.
- shell.js: new block parser (_parseBlocks) and executor (_runInstructions/_runSimple/_runCommand/
_dispatchOne). `for`/`while`/`if` bodies now run, including one-line `if [ -f x ]; then y; fi`.
Variables expand through _expandVars ($VAR, ${VAR}, $?, single quotes literal), globs expand
through _expandGlobs (unquoted args only, per-command), and `>`/`>>` capture the command's output
into the VFS instead of being passed to the command as text.
- shell.js: a script never answers an unknown command by calling the AI any more. It prints
`script:<path>:<line>: <cmd>: command not found` and sets $? — the AI fallback stays for typing.
- shell.js: unclosed blocks and bad `for` headers surface as `Script <path> failed: …` (the parse
used to sit outside the try, so a bad script crashed the runner).
- shell.js: interactive path gets the same treatment — prompt globbing and `echo hi > /f` now work.
Cause of the asymmetry: the two code paths had separate dispatch.
- shell.js: `_isPipeAware` no longer injects the __pipebuf__ file into wc (it reads _pipeInput), and
the module registers itself with a versioned URL and calls its own init() — registerModule() does
not call init(), so the executeCommand wrapper was never installed from the boot bundle.
- module-fs.js: wc/head/tail/grep read piped stdin when no file operand is given; `cat` accepts
several operands (`cat *.txt`); `ls <file>` names the file instead of printing "(empty)".
- main.html: shell.js removed from the extension list — it is already inside
critical-ext-bundle.js, and loading it twice produced two ShellModule instances that could
disagree about their version.
Verification:
- test-shell-script.js 27 asserts (parser, variables, globs, executor, redirect, command-not-found,
one-line if, parse error). RED on HEAD: `TypeError: shell._parseBlocks is not a function`.
- test-shell-stdin.js 16 asserts (added by a subagent): wc -l/-c, head -n, tail -n over stdin, and
the file-argument path unchanged.
- Suite 18/18 files; maintain --fix "node tests 18/18 pass".
- Live on v4.6.115, through the real executeCommand:
`ls /n*.txt` -> /n1.txt · `cat /n*.txt` -> one|two · `echo hi > /r.txt` then `cat /r.txt` -> hi ·
`cat /probe.txt | wc -l` -> 3 (bare, no __pipebuf__) ·
`script /run.sh` -> item 1 / item 2 / item 3 / exists / command not found, exit 1, no AI routing.
| -rw-r--r-- | main.html | 8 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 62 | ||||
| -rw-r--r-- | modules/core/module-fs.js | 62 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 7 | ||||
| -rw-r--r-- | modules/ext/critical-ext-bundle.js | 362 | ||||
| -rw-r--r-- | modules/ext/shell.js | 362 | ||||
| -rw-r--r-- | modules/test/test-shell-script.js | 150 | ||||
| -rw-r--r-- | modules/test/test-shell-stdin.js | 115 |
8 files changed, 977 insertions, 151 deletions
@@ -1312,7 +1312,7 @@ var mods = [ ['Foundation (always loaded)', ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js'], true], ['Built-in modules', ['module-system.js','module-fs.js','module-ollama.js','module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js','module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js','module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js','module-fetcher.js','module-chatstudio.js'], false], - ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js','editor.js','diff.js','alias.js','finder.js','filemgr.js','cryptogzip.js','tutorial.js','shell.js','busybox.js','floating.js','console.js','kanban.js','course.js'], false] + ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js','editor.js','diff.js','alias.js','finder.js','filemgr.js','cryptogzip.js','tutorial.js','busybox.js','floating.js','console.js','kanban.js','course.js'], false] ]; var isSel = function(name) { return true; }; // default: all selected mods.forEach(function(section) { @@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.110"></script> +<script src="modules/core/core-bundle.js?v=v4.6.115"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.110"></script> +<script src="modules/core/module-loader.js?v=v4.6.115"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.110"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.115"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 8d6cbda..6f00658 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -6575,7 +6575,12 @@ class FileSystemModule { const showLong = flags.some(f => f.includes('l')); const showHidden = flags.some(f => f.includes('a')); const files = this.terminal.fs.ls(target); - if (!files || files.length === 0) { this.terminal.print('(empty)', 'info'); return; } + if (!files || files.length === 0) { + // `ls somefile` used to print "(empty)": a file operand should name the file, + // which is also what makes `ls *.txt` (glob-expanded to several paths) readable. + if (target && this.terminal.fs.readFile(target) !== null) { this.terminal.print(target, 'info'); return; } + this.terminal.print('(empty)', 'info'); return; + } const filtered = showHidden ? files : files.filter(f => !f.name.startsWith('.')); if (filtered.length === 0) { this.terminal.print('(no visible files, use ls -a)', 'info'); return; } const maxName = Math.max(...filtered.map(f => f.name.length), 8); @@ -6615,12 +6620,19 @@ class FileSystemModule { docs.forEach(f => this.terminal.print(` ${f.name} ${f.size || 0} B`, 'info')); }; this.commands['cat'] = (args) => { - if(!args[0]) return this.terminal.print('Usage: cat <filename>', 'warning'); - const content = this.terminal.fs.readFile(args[0]); - if(content !== null && content !== undefined) { - if (content.length === 0) this.terminal.print('(empty file)', 'info'); - else this.terminal.print(content); - } else this.terminal.print('cat: ' + args[0] + ': No such file', 'error'); + if(!args[0]) return this.terminal.print('Usage: cat <filename> [...]', 'warning'); + // Multiple operands: `cat *.txt` is useless if only the first path is read. + const files = args.filter(a => !String(a).startsWith('-')); + let printed = 0; + files.forEach((f) => { + const content = this.terminal.fs.readFile(f); + if (content !== null && content !== undefined) { + if (content.length === 0) this.terminal.print('(empty file)', 'info'); + else this.terminal.print(content); + printed++; + } else this.terminal.print('cat: ' + f + ': No such file', 'error'); + }); + if (!printed && !files.length) this.terminal.print('Usage: cat <filename> [...]', 'warning'); }; this.commands['echo'] = (args) => { if (!args || args.length === 0) { this.terminal.print(''); return; } @@ -6848,7 +6860,12 @@ class FileSystemModule { else if (headFiles.length && /^\d+$/.test(a)) n = parseInt(a, 10); // head f 40 else headFiles.push(a); } - if (!headFiles.length) return this.terminal.print('Usage: head [-n] <filename> [...] [lines]', 'warning'); + // Piped input: `cat x | head -n 2` — no file operand means stdin, not usage. + const piped = this.terminal ? this.terminal._pipeInput : null; + if (!headFiles.length) { + if (piped === null || piped === undefined) return this.terminal.print('Usage: head [-n] <filename> [...] [lines]', 'warning'); + return this.terminal.print(String(piped).split('\n').slice(0, Math.max(1, n)).join('\n')); + } const headMulti = headFiles.length > 1; headFiles.forEach(file => { const c = this.terminal.fs.readFile(file); @@ -6877,7 +6894,15 @@ class FileSystemModule { // v4.6.66: every path argument (packet §1). A numeric second positional is still // the legacy `tail <file> <count>`, not a filename. const tailFiles = positional.filter((p, i) => !(i > 0 && /^\d+$/.test(p))); - if (!tailFiles.length) return this.terminal.print('Usage: tail [-f] [-n N] <file> [...] — last N lines; -f follows changes (Ctrl+C or Esc to stop)', 'warning'); + // Piped input: `cat x | tail -n 1` — no file operand means stdin, not usage. + const piped = this.terminal ? this.terminal._pipeInput : null; + if (!tailFiles.length) { + if (piped === null || piped === undefined) return this.terminal.print('Usage: tail [-f] [-n N] <file> [...] — last N lines; -f follows changes (Ctrl+C or Esc to stop)', 'warning'); + const _in = String(piped).split('\n'); + // same trailing-newline rule as the file path below + if (_in.length > 1 && _in[_in.length - 1] === '') _in.pop(); + return this.terminal.print(_in.slice(-n).join('\n')); + } if (positional[1] && /^\d+$/.test(positional[1])) n = parseInt(positional[1], 10); // legacy back-compat const tailMulti = tailFiles.length > 1; let shownFiles = tailFiles; @@ -7047,10 +7072,25 @@ class FileSystemModule { else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } else files.push(a); }); - if (!files.length) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename> [...]', 'warning'); + // Piped input: `cat x | wc -l`. The pipe wrapper sets terminal._pipeInput for every + // stage (modules/ext/shell.js), so "no file operand" means "count stdin", not usage. + const piped = this.terminal ? this.terminal._pipeInput : null; + const stdinMode = !files.length && piped !== null && piped !== undefined; + if (!files.length && !stdinMode) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename> [...]', 'warning'); const anyFlag = want.l || want.w || want.c; const rows = []; let total = { l: 0, w: 0, c: 0 }; + if (stdinMode) { + const content = String(piped); + const r = { + file: null, // stdin has no name — GNU wc prints the bare counts + l: content.split('\n').length, + w: content.split(/\s+/).filter(w => w).length, + c: content.length + }; + total.l += r.l; total.w += r.w; total.c += r.c; + rows.push(r); + } files.forEach(file => { const content = this.terminal.fs.readFile(file); if (content === null || content === undefined) { @@ -7073,7 +7113,7 @@ class FileSystemModule { if (want.l || !anyFlag) out.push(r.l); if (want.w || !anyFlag) out.push(r.w); if (want.c || !anyFlag) out.push(r.c); - if (multi || (anyFlag && !(want.l && want.w && want.c))) out.push(name); + if ((multi || (anyFlag && !(want.l && want.w && want.c))) && name !== null) out.push(name); return out.join(' '); }; rows.forEach(r => this.terminal.print(fmt(r, r.file))); diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js index 583cfda..757d1da 100644 --- a/modules/core/module-fs.js +++ b/modules/core/module-fs.js @@ -52,7 +52,12 @@ class FileSystemModule { const showLong = flags.some(f => f.includes('l')); const showHidden = flags.some(f => f.includes('a')); const files = this.terminal.fs.ls(target); - if (!files || files.length === 0) { this.terminal.print('(empty)', 'info'); return; } + if (!files || files.length === 0) { + // `ls somefile` used to print "(empty)": a file operand should name the file, + // which is also what makes `ls *.txt` (glob-expanded to several paths) readable. + if (target && this.terminal.fs.readFile(target) !== null) { this.terminal.print(target, 'info'); return; } + this.terminal.print('(empty)', 'info'); return; + } const filtered = showHidden ? files : files.filter(f => !f.name.startsWith('.')); if (filtered.length === 0) { this.terminal.print('(no visible files, use ls -a)', 'info'); return; } const maxName = Math.max(...filtered.map(f => f.name.length), 8); @@ -92,12 +97,19 @@ class FileSystemModule { docs.forEach(f => this.terminal.print(` ${f.name} ${f.size || 0} B`, 'info')); }; this.commands['cat'] = (args) => { - if(!args[0]) return this.terminal.print('Usage: cat <filename>', 'warning'); - const content = this.terminal.fs.readFile(args[0]); - if(content !== null && content !== undefined) { - if (content.length === 0) this.terminal.print('(empty file)', 'info'); - else this.terminal.print(content); - } else this.terminal.print('cat: ' + args[0] + ': No such file', 'error'); + if(!args[0]) return this.terminal.print('Usage: cat <filename> [...]', 'warning'); + // Multiple operands: `cat *.txt` is useless if only the first path is read. + const files = args.filter(a => !String(a).startsWith('-')); + let printed = 0; + files.forEach((f) => { + const content = this.terminal.fs.readFile(f); + if (content !== null && content !== undefined) { + if (content.length === 0) this.terminal.print('(empty file)', 'info'); + else this.terminal.print(content); + printed++; + } else this.terminal.print('cat: ' + f + ': No such file', 'error'); + }); + if (!printed && !files.length) this.terminal.print('Usage: cat <filename> [...]', 'warning'); }; this.commands['echo'] = (args) => { if (!args || args.length === 0) { this.terminal.print(''); return; } @@ -325,7 +337,12 @@ class FileSystemModule { else if (headFiles.length && /^\d+$/.test(a)) n = parseInt(a, 10); // head f 40 else headFiles.push(a); } - if (!headFiles.length) return this.terminal.print('Usage: head [-n] <filename> [...] [lines]', 'warning'); + // Piped input: `cat x | head -n 2` — no file operand means stdin, not usage. + const piped = this.terminal ? this.terminal._pipeInput : null; + if (!headFiles.length) { + if (piped === null || piped === undefined) return this.terminal.print('Usage: head [-n] <filename> [...] [lines]', 'warning'); + return this.terminal.print(String(piped).split('\n').slice(0, Math.max(1, n)).join('\n')); + } const headMulti = headFiles.length > 1; headFiles.forEach(file => { const c = this.terminal.fs.readFile(file); @@ -354,7 +371,15 @@ class FileSystemModule { // v4.6.66: every path argument (packet §1). A numeric second positional is still // the legacy `tail <file> <count>`, not a filename. const tailFiles = positional.filter((p, i) => !(i > 0 && /^\d+$/.test(p))); - if (!tailFiles.length) return this.terminal.print('Usage: tail [-f] [-n N] <file> [...] — last N lines; -f follows changes (Ctrl+C or Esc to stop)', 'warning'); + // Piped input: `cat x | tail -n 1` — no file operand means stdin, not usage. + const piped = this.terminal ? this.terminal._pipeInput : null; + if (!tailFiles.length) { + if (piped === null || piped === undefined) return this.terminal.print('Usage: tail [-f] [-n N] <file> [...] — last N lines; -f follows changes (Ctrl+C or Esc to stop)', 'warning'); + const _in = String(piped).split('\n'); + // same trailing-newline rule as the file path below + if (_in.length > 1 && _in[_in.length - 1] === '') _in.pop(); + return this.terminal.print(_in.slice(-n).join('\n')); + } if (positional[1] && /^\d+$/.test(positional[1])) n = parseInt(positional[1], 10); // legacy back-compat const tailMulti = tailFiles.length > 1; let shownFiles = tailFiles; @@ -524,10 +549,25 @@ class FileSystemModule { else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } else files.push(a); }); - if (!files.length) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename> [...]', 'warning'); + // Piped input: `cat x | wc -l`. The pipe wrapper sets terminal._pipeInput for every + // stage (modules/ext/shell.js), so "no file operand" means "count stdin", not usage. + const piped = this.terminal ? this.terminal._pipeInput : null; + const stdinMode = !files.length && piped !== null && piped !== undefined; + if (!files.length && !stdinMode) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename> [...]', 'warning'); const anyFlag = want.l || want.w || want.c; const rows = []; let total = { l: 0, w: 0, c: 0 }; + if (stdinMode) { + const content = String(piped); + const r = { + file: null, // stdin has no name — GNU wc prints the bare counts + l: content.split('\n').length, + w: content.split(/\s+/).filter(w => w).length, + c: content.length + }; + total.l += r.l; total.w += r.w; total.c += r.c; + rows.push(r); + } files.forEach(file => { const content = this.terminal.fs.readFile(file); if (content === null || content === undefined) { @@ -550,7 +590,7 @@ class FileSystemModule { if (want.l || !anyFlag) out.push(r.l); if (want.w || !anyFlag) out.push(r.w); if (want.c || !anyFlag) out.push(r.c); - if (multi || (anyFlag && !(want.l && want.w && want.c))) out.push(name); + if ((multi || (anyFlag && !(want.l && want.w && want.c))) && name !== null) out.push(name); return out.join(' '); }; rows.forEach(r => this.terminal.print(fmt(r, r.file))); diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 329d0f7..29a1b62 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,8 +4,13 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.110'; + const LATEST_VER = 'v4.6.115'; const CACHE = '?v=' + LATEST_VER; + // Exposed so ext modules can register their own URL with the same cache-busting version + // instead of an unversioned path the browser may already have cached (shell.js did). + if (typeof window !== 'undefined' && window.webx11 && window.webx11.moduleManager) { + window.webx11.moduleManager.LATEST_VER = LATEST_VER; + } const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { localStorage.setItem('webx11_cache_ver', LATEST_VER); 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 === */ diff --git a/modules/ext/shell.js b/modules/ext/shell.js index 9966e0f..07f175b 100644 --- a/modules/ext/shell.js +++ b/modules/ext/shell.js @@ -39,8 +39,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 @@ -90,9 +97,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); @@ -102,7 +110,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(); @@ -114,7 +126,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'); @@ -443,8 +455,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 ── @@ -561,66 +575,290 @@ 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); } + } } diff --git a/modules/test/test-shell-script.js b/modules/test/test-shell-script.js new file mode 100644 index 0000000..578b186 --- /dev/null +++ b/modules/test/test-shell-script.js @@ -0,0 +1,150 @@ +// modules/test/test-shell-script.js — run: node +// Covers the script runner: block parser (A), variables (B), globs (C), and the executor (D). +// D is the part that used to be impossible — a loop body could not run at all. +const fsMod = require('fs'), path = require('path'); +const ROOT = path.join(__dirname, '..'); +const store = {}; +global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; +global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } }; +global.window = global; +const load = (rel, cls) => new Function(fsMod.readFileSync(path.join(ROOT, rel), 'utf8') + '\nreturn ' + cls + ';')(); +const Shell = load('ext/shell.js', 'ShellModule'); +const VFS = load('core/virtual-fs.js', 'VirtualFileSystem'); + +let fails = 0, passes = 0; +const ok = (name, cond, detail) => { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); }; + +const shell = Object.create(Shell.prototype); + +// A. block parser +{ + const tree = shell._parseBlocks(['for i in 1 2 3', 'do', ' echo item $i', 'done']); + ok('A1 one top-level instruction', tree.length === 1, JSON.stringify(tree)); + ok('A2 it is a for loop over 3 items', tree[0].op === 'for' && tree[0].items.length === 3, JSON.stringify(tree[0])); + ok('A3 the body holds the echo', tree[0].body.length === 1 && /echo item \$i/.test(tree[0].body[0].text)); +} +{ + const tree = shell._parseBlocks(['if test -f /x', 'then', ' echo yes', 'else', ' echo no', 'fi']); + ok('A4 if/else parsed', tree[0].op === 'if' && tree[0].body.length === 1 && tree[0].elseBody.length === 1, JSON.stringify(tree)); +} +{ + let threw = null; + try { shell._parseBlocks(['for i in 1 2', ' echo $i']); } catch (e) { threw = e.message; } + ok('A5 an unclosed block is an error, not a silent run', !!threw && /done/.test(threw), String(threw)); +} + +// B. variables +{ + const env = { i: '2', name: 'webx11' }; + ok('B1 $VAR expands', shell._expandVars('echo $name', env) === 'echo webx11'); + ok('B2 ${VAR} expands', shell._expandVars('echo ${name}!', env) === 'echo webx11!'); + ok('B3 single quotes are literal', shell._expandVars("echo '$name'", env) === "echo '$name'"); + ok('B4 unknown var becomes empty', shell._expandVars('echo [$nope]', env) === 'echo []'); +} + +// C. globbing (against a fake fs) +{ + const fake = { ls: () => [{ name: 'a.txt', type: 'file' }, { name: 'b.txt', type: 'file' }, { name: 'c.log', type: 'file' }], readDir: () => [] }; + shell.terminal = { fs: fake, moduleManager: { getAllCommands: () => ({}) } }; + const out = shell._expandGlobs(['*.txt'], ['/work']); + ok('C1 *.txt expands to two files', out.length === 2 && out.join(',') === '/work/a.txt,/work/b.txt', JSON.stringify(out)); + ok('C2 a literal arg is untouched', shell._expandGlobs(['plain'], ['/work']).join() === 'plain'); + ok('C3 quoted wildcard stays literal', shell._expandGlobs(["'*.txt'"], ['/work']).join() === "'*.txt'"); +} + +// D. the executor (a fake terminal, a real VFS) +function mkShell(seed) { + const vfs = new VFS(); + vfs.saveToStorage = () => true; + Object.entries(seed || {}).forEach(([p, body]) => { vfs.writeFile(p, body); }); + const printed = []; + const term = { + fs: vfs, + print: (t) => { + const st = term._printCaptureStack || []; + if (st.length) { st[st.length - 1].output += String(t) + '\n'; return; } + printed.push(String(t)); + }, + log: () => {}, markDirty: () => {}, + moduleManager: { getAllCommands: () => ({ ls: {}, echo: {}, cat: {} }) }, + security: { checkAndRun: async () => { printed.push('AI-ROUTED'); } }, + _printCaptureStack: [], + splitPipe: (s) => s.split('|').map(x => x.trim()), + executePipeChain: async () => {}, + }; + const sh = Object.create(Shell.prototype); + sh.terminal = term; + sh.commands = { echo: (args) => term.print(args.join(' ')) }; + return { sh, term, printed, vfs }; +} + +async function runScript(seed, lines) { + const env = { $: 0 }; + const ctx = { path: '/t.sh', inScript: true }; + const tree = (await (async () => { const { sh } = mkShell(seed); return sh._parseBlocks(lines); })()); + const { sh, printed, vfs, term } = mkShell(seed); + await sh._runInstructions(tree, env, ctx); + return { printed, vfs, env, term }; +} + +(async () => { + { + const r = await runScript({}, ['for i in 1 2 3', 'do', 'echo item $i', 'done']); + ok('D1 the loop body ran 3 times', r.printed.join('|') === 'item 1|item 2|item 3', r.printed.join('|')); + ok('D2 $i is not left over as a literal', !r.printed.some(l => /\$i/.test(l)), r.printed.join('|')); + } + { + const r = await runScript({ '/present.txt': 'x' }, ['if [ -f /present.txt ]', 'then', 'echo yes', 'else', 'echo no', 'fi']); + ok('D3 bracket test takes the then-branch', r.printed.join('|') === 'yes', r.printed.join('|')); + const r2 = await runScript({}, ['if test -f /absent.txt', 'then', 'echo yes', 'else', 'echo no', 'fi']); + ok('D4 a failing test takes the else-branch', r2.printed.join('|') === 'no', r2.printed.join('|')); + } + { + const r = await runScript({}, ['echo hi > /o.txt']); + ok('D5 redirect writes the file', r.vfs.readFile('/o.txt') === 'hi\n', JSON.stringify(r.vfs.readFile('/o.txt'))); + ok('D6 redirect does not echo to the screen', !r.printed.includes('hi > /o.txt'), r.printed.join('|')); + } + { + const vfs = new VFS(); vfs.saveToStorage = () => true; + vfs.mkdirRecursive('/work'); + vfs.writeFile('/work/a.txt', '1'); vfs.writeFile('/work/b.txt', '2'); vfs.writeFile('/work/c.log', '3'); + const seen = []; + const term = { fs: vfs, print: () => {}, log: () => {}, markDirty: () => {}, + moduleManager: { getAllCommands: () => ({ ls: {} }) }, + security: { checkAndRun: async (s) => { seen.push(s); } }, + _printCaptureStack: [], splitPipe: (s) => [s], executePipeChain: async () => {} }; + const sh = Object.create(Shell.prototype); + sh.terminal = term; sh.commands = {}; + vfs.currentPath = ['work']; + await sh._runSimple('ls *.txt', { $: 0 }, { path: '/t.sh', inScript: true }); + ok('D15 glob results reach the dispatched command', seen[0] === 'ls /work/a.txt /work/b.txt', JSON.stringify(seen)); + } + { + const r = await runScript({}, ['no-such-cmd arg']); + ok('D7 unknown command is reported, not answered by the AI', r.printed.join('|').includes('command not found'), r.printed.join('|')); + ok('D8 the AI fallback was not used', !r.printed.includes('AI-ROUTED'), r.printed.join('|')); + ok('D9 exit status is non-zero', r.env.$ !== 0, String(r.env.$)); + } + { + const r = await runScript({ '/present.txt': 'x' }, ['if [ -f /present.txt ]; then echo one-line; fi']); + ok('D11 a one-line if runs its then-branch', r.printed.join('|') === 'one-line', r.printed.join('|')); + } + { + const { sh, term, printed } = mkShell({}); + sh.terminal.fs.writeFile('/bad.sh', 'for i in 1 2\necho $i\n'); + await sh._cmdScript(['/bad.sh']); + ok('D12 an unclosed block fails as a script error', printed.join('|').includes('failed:'), printed.join('|')); + ok('D13 the failure names the missing terminator', /done/.test(printed.join('|')), printed.join('|')); + } + { + const r = await runScript({}, ['for i in 1 2 3', 'do', 'echo n $i', 'done', 'echo after']); + ok('D14 a statement after the loop still runs', r.printed[r.printed.length - 1] === 'after', r.printed.join('|')); + } + { + const r = await runScript({}, ['echo a', 'echo b']); + ok('D10 sequential statements all run', r.printed.join('|') === 'a|b', r.printed.join('|')); + } + + if (fails === 0) console.log('test-shell-script: ' + passes + ' passed, 0 failed'); + else { console.log('test-shell-script: ' + passes + ' passed, ' + fails + ' FAILED'); process.exit(1); } +})(); diff --git a/modules/test/test-shell-stdin.js b/modules/test/test-shell-stdin.js new file mode 100644 index 0000000..10ba190 --- /dev/null +++ b/modules/test/test-shell-stdin.js @@ -0,0 +1,115 @@ +// modules/test/test-shell-stdin.js — run: node +// Pipes: wc/head/tail must read stdin when no file operand is given, and must still read the file +// when one is (ignoring a stale pipe buffer). The channel is terminal._pipeInput — the property +// the pipe wrapper in modules/ext/shell.js sets before dispatching each stage. +const fsMod = require('fs'), path = require('path'); +const ROOT = path.join(__dirname, '..'); +const src = fsMod.readFileSync(path.join(ROOT, 'core/module-fs.js'), 'utf8'); +const FileSystemModule = new Function(src + '\nreturn FileSystemModule;')(); + +let fails = 0, passes = 0; +const ok = (name, cond, detail) => { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); }; + +// A fake terminal whose fs knows only the files it is handed: there is no __pipebuf__ magic here, +// so a handler that quietly searched the cwd instead of reading stdin would fail, not pass. +const mk = (files, pipeInput) => { + const out = []; + const term = { + fs: { + readFile: (p) => (files && Object.prototype.hasOwnProperty.call(files, p)) ? files[p] : null, + getCurrentDir: () => ({ name: 'fake', content: {} }), + }, + _pipeInput: pipeInput === undefined ? null : pipeInput, + print: (msg) => out.push(String(msg)), + }; + const m = new FileSystemModule(); + m.terminal = term; + m.registerCommands(); + return { m, out }; +}; +const last = (out) => out.length ? out[out.length - 1] : '<nothing printed>'; + +// (a) wc -l over 3 lines of stdin +{ + const { m, out } = mk({}, 'alpha\nbeta\ngamma'); + m.commands.wc(['-l']); + ok('A1 wc -l on 3 piped lines prints 3', out.length === 1 && out[0].trim() === '3', JSON.stringify(out)); +} +// (b) wc -c counts characters, not lines +{ + const { m, out } = mk({}, 'abcdef'); + m.commands.wc(['-c']); + ok('B1 wc -c on "abcdef" piped prints 6', out.length === 1 && out[0].trim() === '6', JSON.stringify(out)); + + const w = mk({}, 'abcdef'); + w.m.commands.wc(['-w']); + ok('B2 wc -w on one piped word prints 1', w.out.length === 1 && w.out[0].trim() === '1', JSON.stringify(w.out)); + + const l = mk({}, 'abcdef'); + l.m.commands.wc(['-l']); + ok('B3 wc -l on the same piped text still prints 1 (so -c is not counting lines)', l.out.length === 1 && l.out[0].trim() === '1', JSON.stringify(l.out)); +} +// (c) head -n 2 returns the first 2 piped lines +{ + const { m, out } = mk({}, 'alpha\nbeta\ngamma'); + m.commands.head(['-n', '2']); + ok('C1 head -n 2 on piped stdin prints 2 lines', out.length === 1 && out[0] === 'alpha\nbeta', JSON.stringify(out)); + + const d = mk({}, 'alpha\nbeta\ngamma'); + d.m.commands.head([]); + ok('C2 head with no operand/number defaults to 10 piped lines', d.out.length === 1 && d.out[0] === 'alpha\nbeta\ngamma', JSON.stringify(d.out)); +} +// (d) tail -n 1 returns the last piped line +{ + const { m, out } = mk({}, 'alpha\nbeta\ngamma'); + m.commands.tail(['-n', '1']); + ok('D1 tail -n 1 on piped stdin prints the last line', out.length === 1 && out[0] === 'gamma', JSON.stringify(out)); + + const t = mk({}, 'alpha\nbeta\ngamma'); + t.m.commands.tail(['-2']); + ok('D2 tail -2 on piped stdin prints the last 2 lines', t.out.length === 1 && t.out[0] === 'beta\ngamma', JSON.stringify(t.out)); + + const nl = mk({}, 'alpha\nbeta\ngamma\n'); + nl.m.commands.tail(['-n', '1']); + ok('D3 tail -n 1 ignores a trailing newline (no blank line)', nl.out.length === 1 && nl.out[0] === 'gamma', JSON.stringify(nl.out)); +} +// (e) a real file operand still wins: the file is read and the pipe buffer is ignored +{ + const files = { '/probe.txt': 'one\ntwo\nthree\nfour' }; + const { m, out } = mk(files, 'PIPED\nJUNK\nHERE'); + m.commands.wc(['-l', '/probe.txt']); + ok('E1 wc -l <file> counts the file (4), not stdin', out.length === 1 && out[0].trim() === '4 /probe.txt', JSON.stringify(out)); + + const h = mk(files, 'PIPED\nJUNK\nHERE'); + h.m.commands.head(['-n', '1', '/probe.txt']); + ok('E2 head -n 1 <file> prints the file\'s first line', h.out.length === 1 && h.out[0] === 'one', JSON.stringify(h.out)); + + const t = mk(files, 'PIPED\nJUNK\nHERE'); + t.m.commands.tail(['-n', '1', '/probe.txt']); + ok('E3 tail -n 1 <file> prints the file\'s last line', t.out.length === 1 && t.out[0] === 'four', JSON.stringify(t.out)); + + const d = mk(files, 'PIPED\nJUNK\nHERE'); + d.m.commands.wc(['/probe.txt']); + // Real behaviour (verified identical on the pre-change file): with no flag and a single + // file, wc prints the bare counts and no name — the name is only appended when there are + // several rows or when a flag was given. + ok('E4 wc <file> with no flags keeps its long format (l w c, no name for one file)', + d.out.length === 1 && d.out[0] === '4 4 18' && d.out[0].indexOf('PIPED') === -1, JSON.stringify(d.out)); +} +// (f) no operand and no pipe must still be usage, not a crash or a silent empty read +{ + const w = mk({}, null); + w.m.commands.wc(['-l']); + ok('F1 wc with no file and no stdin prints usage', /^Usage: wc/.test(w.out[0] || ''), JSON.stringify(w.out)); + + const h = mk({}); + h.m.commands.head(['-n', '2']); + ok('F2 head with no file and no stdin prints usage', /^Usage: head/.test(h.out[0] || ''), JSON.stringify(h.out)); + + const t = mk({}, null); + t.m.commands.tail(['-n', '1']); + ok('F3 tail with no file and no stdin prints usage', /^Usage: tail/.test(t.out[0] || ''), JSON.stringify(t.out)); +} + +console.log(passes + ' passed, ' + fails + ' failed'); +if (fails) process.exit(1); |