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