aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-27 16:23:21 +0100
committerAgando <roduccomm@gmail.com>2026-10-27 16:23:21 +0100
commitcd71cc814f0d51c2d71fca4231690228a1e1da83 (patch)
treec123b3695bc34c6bba87201f448f160872efb33e
parent7e4e30a1790ced2b745a73e9c83e41d6e224bac6 (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.html8
-rw-r--r--modules/core/core-bundle.js62
-rw-r--r--modules/core/module-fs.js62
-rw-r--r--modules/core/module-loader.js7
-rw-r--r--modules/ext/critical-ext-bundle.js362
-rw-r--r--modules/ext/shell.js362
-rw-r--r--modules/test/test-shell-script.js150
-rw-r--r--modules/test/test-shell-stdin.js115
8 files changed, 977 insertions, 151 deletions
diff --git a/main.html b/main.html
index 8048efb..9496a15 100644
--- a/main.html
+++ b/main.html
@@ -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);