aboutsummaryrefslogtreecommitdiff
path: root/modules/core
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 /modules/core
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.
Diffstat (limited to 'modules/core')
-rw-r--r--modules/core/core-bundle.js62
-rw-r--r--modules/core/module-fs.js62
-rw-r--r--modules/core/module-loader.js7
3 files changed, 108 insertions, 23 deletions
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);