// Critical ext module bundle// Generated from /var/www/webx11.duckdns.org/spaces/newos4/modules/ext
/* === editor.js === */
class EditorModule {
constructor() {
this.commands = {};
this.terminal = null;
this._vfsPath = null;
}
requirements() {
return { met: true, missing: [] };
}
getHelpInfo() {
return {
'edit': 'Open the code editor for a VFS file (usage: edit )',
};
}
registerCommands() {
this.commands['edit'] = (args) => this.edit(args);
}
edit(args) {
if (!args[0]) return this.terminal.print('Usage: edit ', 'warning');
const path = args.join(' ');
const modal = document.getElementById('code-editor');
const title = document.getElementById('code-editor-title');
const textarea = document.getElementById('code-editor-textarea');
const gutter = document.getElementById('code-editor-gutter');
const statusEl = document.getElementById('code-editor-status');
if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error');
// Read existing file content or use empty for new files
const content = this.terminal.fs.readFile(path);
const fileContent = (content !== null && content !== false) ? content : '';
// Set up modal
this._vfsPath = path;
title.textContent = '๐ Editing: ' + path;
textarea.value = fileContent;
modal.style.display = 'flex';
textarea.focus();
// Editor enhancements: line numbers, Tab, Ctrl+S
const updateGutter = () => {
const lines = textarea.value.split('\n').length;
let html = '';
for (let i = 1; i <= lines; i++) html += i + '\n';
gutter.textContent = '';
gutter.textContent = html;
};
const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };
const updateStatus = () => {
const lines = textarea.value.split('\n').length;
const chars = textarea.value.length;
statusEl.textContent = `${lines} lines ยท ${chars} chars`;
};
updateGutter();
updateStatus();
textarea.onscroll = syncScroll;
textarea.oninput = () => { updateGutter(); updateStatus(); };
textarea.onkeydown = (e) => {
if (e.key === 'Tab') { e.preventDefault(); insertTab(textarea, e.shiftKey); updateGutter(); }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveBtn.click(); }
};
function insertTab(el, shift) {
const s = el.selectionStart, e2 = el.selectionEnd;
if (shift) {
const before = el.value.substring(0, s);
const lineStart = before.lastIndexOf('\n') + 1;
if (lineStart < s && el.value.substring(lineStart, lineStart + 1) === '\t') {
el.value = el.value.substring(0, lineStart) + el.value.substring(lineStart + 1);
el.selectionStart = el.selectionEnd = s - 1;
}
} else {
el.value = el.value.substring(0, s) + '\t' + el.value.substring(e2);
el.selectionStart = el.selectionEnd = s + 1;
}
}
// Fullscreen toggle
window._editorToggleFullscreen = () => {
const content = modal.querySelector('.modal-content');
if (content.style.maxWidth === '100vw') {
content.style.maxWidth = '90vw'; content.style.maxHeight = '90vh'; content.style.width = '900px'; content.style.height = '80vh';
} else {
content.style.maxWidth = '100vw'; content.style.maxHeight = '100vh'; content.style.width = '100vw'; content.style.height = '100vh';
}
};
// Override save button to write back to VFS
const saveBtn = modal.querySelector('.modal-footer .btn:last-child');
const cancelBtn = modal.querySelector('.modal-footer .btn-danger');
if (saveBtn) {
saveBtn._origOnclick = saveBtn.onclick;
saveBtn.onclick = async () => {
const code = textarea.value;
const ok = this.terminal.fs.writeFile(this._vfsPath, code);
if (ok) {
this.terminal.print('โ
Saved to ' + this._vfsPath, 'success');
// Mirror to _mods/ for test_module compatibility
const modMatch = this._vfsPath.match(/(?:^|\/)(\w+)\.js$/);
if (modMatch) {
const modName = modMatch[1];
this.terminal.fs.mkdirRecursive('_mods');
this.terminal.fs.writeFile('_mods/' + modName + '.js', code);
if (!this.terminal.fs.readFile('_mods/' + modName + '.orig.js')) {
this.terminal.fs.writeFile('_mods/' + modName + '.orig.js', 'placeholder');
}
this.terminal.print('๐ Mirrored to _mods/' + modName + '.js for live testing', 'info');
}
// Notify tutorial module for quest 4 (edit + save)
if (this.terminal.tutorialModule) {
this.terminal.tutorialModule.onFileSaved(this._vfsPath);
}
} else {
this.terminal.print('โ Failed to save ' + this._vfsPath, 'error');
}
modal.style.display = 'none';
this._vfsPath = null;
// Restore original onclick handler
if (saveBtn._origOnclick) saveBtn.onclick = saveBtn._origOnclick;
delete saveBtn._origOnclick;
};
}
if (cancelBtn) {
cancelBtn._origOnclick = cancelBtn.onclick;
cancelBtn.onclick = () => {
modal.style.display = 'none';
this._vfsPath = null;
if (cancelBtn._origOnclick) cancelBtn.onclick = cancelBtn._origOnclick;
delete cancelBtn._origOnclick;
};
}
}
}
if (typeof window.webx11 !== 'undefined' && window.webx11) {
webx11.moduleManager.registerModule(new EditorModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/editor.js');
}
/* === shell.js === */
class ShellModule {
constructor() {
this.commands = {};
this.terminal = null;
this._vars = {
HOME: '/',
USER: 'darkbot',
SHELL: 'webx11-shell',
TERM: 'xterm-256color',
PWD: '/'
};
this._scriptDepth = 0;
this._maxScriptDepth = 3;
}
requirements() {
return { met: true, missing: [] };
}
getHelpInfo() {
return {
'export': 'export = โ Set a shell variable',
'unset': 'unset โ Remove a shell variable',
'script': 'script โ Run a shell script from VFS',
'$': '$ โ Execute with shell parsing (default: auto-detected)',
'sh': 'sh โ Run a shell script from VFS (alias for script)',
'set': 'set โ List all shell variables',
};
}
registerCommands() {
this.commands['export'] = (args) => this._cmdExport(args);
this.commands['unset'] = (args) => this._cmdUnset(args);
this.commands['script'] = (args) => this._cmdScript(args);
this.commands['sh'] = (args) => this._cmdScript(args);
this.commands['set'] = () => this._cmdSet();
}
// โโ Hook into terminal.executeCommand โโ
init() {
// 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
const origExecute = this.terminal.executeCommand.bind(this.terminal);
const self = this;
this.terminal.executeCommand = async function(rawInput) {
if (!rawInput.trim()) return;
this.markDirty();
this.printUserPrompt(rawInput);
// Save to command history (preserved from original executeCommand)
const cmdRaw = rawInput.trim();
if (this.history.length === 0 || this.history[this.history.length - 1] !== cmdRaw) {
this.history.push(cmdRaw);
}
if (this.history.length > this._historyCap) {
this.history = this.history.slice(-this._historyCap);
}
this.historyIndex = this.history.length;
this._saveCommandHistory();
// For simple single-word commands without special chars, skip shell parsing
if (/^[a-zA-Z0-9._\/-]+$/.test(rawInput.trim()) && !rawInput.includes(' ') && !rawInput.includes('$')) {
try {
return await this.security.checkAndRun(rawInput);
} catch(e) {
this.print('Error: ' + e.message, 'error');
return;
}
}
// Substitute variables in the raw input
const substituted = self._substituteVars(rawInput);
// Split on ; first (sequence)
const seqParts = self._splitSequence(substituted);
for (const seqPart of seqParts) {
if (!seqPart.trim()) continue;
// Split on && and || โ operator now attached to the FOLLOWING segment
const chainParts = self._splitChain(seqPart.trim());
let lastOk = true;
for (const chainPart of chainParts) {
if (!chainPart.cmd.trim()) continue;
const { cmd: chainCmd, operator } = chainPart;
// Check the operator that PRECEDES this command
if (operator === '&&' && !lastOk) continue;
if (operator === '||' && lastOk) continue;
// Check for redirects
const redirectInfo = self._extractRedirects(chainCmd);
if (redirectInfo.hasRedirect) {
// 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);
if (pipeParts.length > 1) {
await this.executePipeChain(pipeParts);
} else {
// Single command โ check for shell built-ins first
const parts = chainCmd.split(/\s+/);
const cmd = parts[0].toLowerCase();
// 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();
await self.commands[cmd](args);
lastOk = true;
} catch(e) {
this.print('Error: ' + e.message, 'error');
lastOk = false;
}
} else {
try {
await this.security.checkAndRun(expandedCmd);
lastOk = true;
} catch(e) {
this.print('Error: ' + e.message, 'error');
lastOk = false;
}
}
}
}
}
}
};
this.terminal._shellModule = this;
// Modify pipe chain to add pipe-aware injection and security checks
const origPipeChain = this.terminal.executePipeChain.bind(this.terminal);
this.terminal.executePipeChain = async function(pipeParts) {
// Write pipe input from previous segment as temp file for next segment
const shellMod = this._shellModule;
let pipeInput = null;
for (let i = 0; i < pipeParts.length; i++) {
const cmdText = pipeParts[i];
const parts = cmdText.split(/\s+/);
const cmd = parts[0].toLowerCase();
let args = parts.slice(1);
// Security gate โ replicate the original executePipeChain checks
const cls = this.security._classify(cmdText);
if (cls.destructive && this.security.level > 0) {
this.print(`โ ๏ธ Security: Destructive command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error');
return;
}
if (cls.system && this.security.level > 0) {
this.print(`โ ๏ธ Security: System command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error');
return;
}
if (!cls.known && this.security.level >= 2) {
this.print(`โ ๏ธ Security: Unknown command "${cmd}" blocked in pipe (paranoid mode)`, 'error');
return;
}
this._pipeInput = pipeInput;
if (pipeInput !== null) {
// Write pipe input as temp file so file-reading commands can use it
this.fs.writeFile('__pipebuf__', pipeInput);
// Inject __pipebuf__ as file arg if command is pipe-aware (reads files for input)
if (shellMod && shellMod._isPipeAware(cmd)) {
// Don't inject if command already has enough file args
if (!args.some(a => !a.startsWith('-') && a !== '|' && a !== '>')) {
args = args.concat('__pipebuf__');
}
}
}
try {
if (i < pipeParts.length - 1) {
pipeInput = await this.capturePipeOutput(cmd, args);
} else {
await this.dispatchCommand(cmd, args);
}
} finally {
// Clean up temp file
if (this.fs.readFile('__pipebuf__') !== null) {
delete this.fs.root.content['__pipebuf__'];
}
}
}
this._pipeInput = null;
};
}
// โโ Tokenizer helpers โโ
_splitSequence(input) {
// Split on ; (not inside quotes)
const parts = [];
let current = '';
let inQuote = false;
let quoteChar = '';
for (const ch of input) {
if ((ch === '"' || ch === "'") && !inQuote) {
inQuote = true; quoteChar = ch; current += ch;
} else if (ch === quoteChar && inQuote) {
inQuote = false; quoteChar = ''; current += ch;
} else if (ch === ';' && !inQuote) {
parts.push(current.trim());
current = '';
} else {
current += ch;
}
}
parts.push(current.trim());
return parts;
}
_splitChain(input) {
// Split on && and ||, preserving operator info
// Operator is attached to the FOLLOWING segment (the one it guards)
const parts = [];
let current = '';
let inQuote = false;
let quoteChar = '';
let pendingOp = null; // operator from previous split, applies to next segment
let i = 0;
while (i < input.length) {
const ch = input[i];
if ((ch === '"' || ch === "'") && !inQuote) {
inQuote = true; quoteChar = ch; current += ch; i++;
} else if (ch === quoteChar && inQuote) {
inQuote = false; quoteChar = ''; current += ch; i++;
} else if (!inQuote && input.substring(i, i+2) === '&&') {
parts.push({ cmd: current.trim(), operator: pendingOp });
pendingOp = '&&';
current = ''; i += 2;
} else if (!inQuote && input.substring(i, i+2) === '||') {
parts.push({ cmd: current.trim(), operator: pendingOp });
pendingOp = '||';
current = ''; i += 2;
} else {
current += ch; i++;
}
}
parts.push({ cmd: current.trim(), operator: pendingOp });
return parts;
}
_extractRedirects(input) {
// Find >, >>, < outside quotes
let cmd = '';
let hasRedirect = false;
let redirectOp = '';
let redirectFile = '';
let append = false;
let inQuote = false, quoteChar = '';
let i = 0;
const parts = [];
let current = '';
while (i < input.length) {
const ch = input[i];
if ((ch === '"' || ch === "'") && !inQuote) {
inQuote = true; quoteChar = ch; current += ch; i++;
} else if (ch === quoteChar && inQuote) {
inQuote = false; quoteChar = ''; current += ch; i++;
} else if (!inQuote) {
if (input.substring(i, i+2) === '>>') {
if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '>>';
append = true;
i += 2;
// Read until next redirect or end
let file = '';
while (i < input.length && input[i] === ' ') i++;
while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') {
file += input[i]; i++;
}
redirectFile = file.trim();
if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
break;
} else if (input.substring(i, i+1) === '>') {
if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '>';
append = false;
i += 1;
let file = '';
while (i < input.length && input[i] === ' ') i++;
while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') {
file += input[i]; i++;
}
redirectFile = file.trim();
if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
break;
} else if (input.substring(i, i+1) === '<') {
if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '<';
i += 1;
let file = '';
while (i < input.length && input[i] === ' ') i++;
while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') {
file += input[i]; i++;
}
redirectFile = file.trim();
if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
break;
} else {
current += ch; i++;
}
} else {
current += ch; i++;
}
}
if (current.trim()) parts.push(current.trim());
const cmdText = parts.join(' ');
return {
hasRedirect,
redirectOp,
redirectFile,
append,
cmdText,
exitOk: true
};
}
async _executeWithRedirect(redirectInfo) {
const { redirectOp, redirectFile, append, cmdText } = redirectInfo;
const fs = this.terminal.fs;
if (redirectOp === '<') {
// Read file content as stdin
const inputContent = fs.readFile(redirectFile);
if (inputContent === null) {
this.terminal.print(`shell: ${redirectFile}: No such file`, 'error');
redirectInfo.exitOk = false;
return;
}
this.terminal._pipeInput = inputContent;
try {
const parts = cmdText.split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
// If command reads files, inject pipe content as temp file
if (this._isPipeAware(cmd)) {
fs.writeFile('__pipebuf__', inputContent);
const newArgs = [...args];
if (!newArgs.some(a => !a.startsWith('-'))) {
newArgs.push('__pipebuf__');
}
try {
await this.terminal.dispatchCommand(cmd, newArgs);
} finally {
if (fs.readFile('__pipebuf__') !== null) {
delete fs.root.content['__pipebuf__'];
}
}
} else {
await this.terminal.dispatchCommand(cmd, args);
}
redirectInfo.exitOk = true;
} catch(e) {
this.terminal.print('Error: ' + e.message, 'error');
redirectInfo.exitOk = false;
} finally {
this.terminal._pipeInput = null;
}
return;
}
// > or >> โ capture output, write to file
let output = '';
this.terminal._pushPrintCapture(buf => { output += buf; });
let cmdOk = true;
try {
// Check for pipes within the command before redirect
const pipeParts = this.terminal.splitPipe(cmdText);
if (pipeParts.length > 1) {
// Execute pipe chain manually to capture all output
let pipeInput = null;
for (let i = 0; i < pipeParts.length; i++) {
const pText = pipeParts[i];
const pParts = pText.split(/\s+/);
const pCmd = pParts[0].toLowerCase();
const pArgs = pParts.slice(1);
if (pipeInput !== null) {
fs.writeFile('__pipebuf__', pipeInput);
}
if (i < pipeParts.length - 1) {
let capt = '';
this.terminal._pushPrintCapture(buf => { capt += buf; });
try {
await this.terminal.dispatchCommand(pCmd, pArgs);
} finally {
this.terminal._popPrintCapture();
}
pipeInput = capt.trim();
} else {
await this.terminal.dispatchCommand(pCmd, pArgs);
}
if (fs.readFile('__pipebuf__') !== null) {
delete fs.root.content['__pipebuf__'];
}
}
} else {
const parts = cmdText.split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
await this.terminal.dispatchCommand(cmd, args);
}
} catch(e) {
cmdOk = false;
// Pop capture before showing the error
this.terminal._popPrintCapture();
this.terminal.print('Error: ' + e.message, 'error');
redirectInfo.exitOk = false;
return;
} finally {
// Always pop capture โ critical: without this, terminal output stays captured
// (empty stack = normal DOM printing, so pop is idempotent-safe when already popped)
if (this.terminal._printCaptureStack && this.terminal._printCaptureStack.length > 0) {
// popped in catch path for error case; success path pops here
this.terminal._popPrintCapture();
}
}
const captured = output.trimEnd();
if (captured) {
if (append) {
const existing = fs.readFile(redirectFile) || '';
fs.writeFile(redirectFile, existing + (existing ? '\n' : '') + captured);
} else {
fs.writeFile(redirectFile, captured);
}
this.terminal.print(`Redirected ${captured.split('\n').length} line(s) to ${redirectFile}`, 'success');
} else {
// Write empty file
if (!append) {
fs.writeFile(redirectFile, '');
this.terminal.print(`Redirected (empty) to ${redirectFile}`, 'success');
}
}
redirectInfo.exitOk = cmdOk;
}
_isPipeAware(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.
// grep/head/tail/wc read terminal._pipeInput themselves (v4.6.111-116). Injecting the
// __pipebuf__ file for them made `cat f | grep x` match twice (the pipe buffer AND the cwd
// file of the same name) and printed "3 __pipebuf__" from wc.
return ['sort', 'cut', 'uniq', 'expand'].includes(cmd);
}
// โโ Variable substitution โโ
_substituteVars(input) {
// Replace $VAR and ${VAR} (not inside single quotes)
let result = '';
let inSingle = false;
let i = 0;
while (i < input.length) {
const ch = input[i];
if (ch === "'") {
inSingle = !inSingle;
result += ch;
i++;
} else if (ch === '$' && !inSingle && i + 1 < input.length) {
// Check for $(
if (input[i+1] === '(') {
result += ch;
i++;
continue;
}
// Check for ${VAR}
if (input[i+1] === '{') {
let end = input.indexOf('}', i);
if (end !== -1) {
const varName = input.substring(i+2, end);
const val = this._getVar(varName);
result += val !== undefined ? val : '$' + varName;
i = end + 1;
continue;
}
}
// Check for $VAR (alphanumeric/underscore)
let j = i + 1;
if (j < input.length && /[A-Za-z_]/.test(input[j])) {
while (j < input.length && /\w/.test(input[j])) j++;
const varName = input.substring(i+1, j);
const val = this._getVar(varName);
result += val !== undefined ? val : '';
i = j;
continue;
}
// $ followed by digit โ not a var
result += ch;
i++;
} else {
result += ch;
i++;
}
}
return result;
}
_getVar(name) {
if (name === 'PWD') return '/' + this.terminal.fs.currentPath.join('/');
return this._vars[name] !== undefined ? this._vars[name] : undefined;
}
_setVar(name, value) {
this._vars[name] = value;
}
// โโ Commands โโ
_cmdExport(args) {
if (!args[0] || !args[0].includes('=')) {
return this.terminal.print('Usage: export =', 'warning');
}
const eqIdx = args[0].indexOf('=');
const name = args[0].substring(0, eqIdx);
let value = args[0].substring(eqIdx + 1);
// Support quoted values: export FOO="hello world"
if (args.length > 1) {
const rest = args.slice(1).join(' ');
if (value.endsWith('"') || value.endsWith("'")) {
// First part ended quote โ fine
} else if (value.startsWith('"') || value.startsWith("'")) {
value = value + (rest ? ' ' + rest : '');
}
}
// Strip surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length - 1);
}
this._setVar(name, value);
this.terminal.print(`Shell ${name}=${value}`, 'success');
}
_cmdUnset(args) {
if (!args[0]) return this.terminal.print('Usage: unset ', 'warning');
delete this._vars[args[0]];
this.terminal.print(`Shell var removed: ${args[0]}`, 'info');
}
_cmdSet() {
const fs = this.terminal.fs;
this.terminal.print('Shell variables:', 'info');
for (const [k, v] of Object.entries(this._vars)) {
this.terminal.print(`${k}=${v}`, 'info');
}
this.terminal.print(`PWD=/${fs.currentPath.join('/')}`, 'info');
}
async _cmdScript(args) {
if (!args[0]) return this.terminal.print('Usage: script ', 'warning');
if (this._scriptDepth >= this._maxScriptDepth) {
return this.terminal.print(`script: max recursion depth (${this._maxScriptDepth}) reached`, 'error');
}
const path = args[0];
const content = this.terminal.fs.readFile(path);
if (content === null) return this.terminal.print(`script: ${path}: No such file`, 'error');
this._scriptDepth++;
try {
this.terminal.print(`Running script: ${path}`, 'info');
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._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 in '");
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 {
const fsv = this.terminal.fs;
// ls() is the canonical lister; readDir() is the fallback so the helper also works
// against a bare fake fs (and against any future module that only implements one).
entries = (fsv && (fsv.ls ? fsv.ls(dirPath) : null)) || (fsv && fsv.readDir ? fsv.readDir(dirPath) : null) || [];
} 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(/^\[\s*/, '') // `[ -f x ]`
.replace(/\s*\]$/, '')
.replace(/^test\s+/, '') // `test -f x` โ without this every `if test` was false
.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 rawItem of node.items) {
const expanded = this._expandVars(rawItem, env);
// `for f in /work/*.txt` used to run ONCE with $f set to the literal pattern,
// because only command arguments went through glob expansion.
const cwd = (this.terminal.fs && this.terminal.fs.currentPath) || [];
const items = this._expandGlobs([expanded], cwd);
for (const item of items) {
env[node.var] = item;
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) {
// 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 === */
class AliasModule {
constructor() {
this.commands = {};
this.terminal = null;
this.key = 'webx11_aliases';
this.defaults = { 'll': 'ls', 'h': 'help', 'cls': 'clear', 'sh': 'script' };
this.aliases = this.load();
this._aliasDepth = 0;
this._aliasMaxDepth = 10;
}
getHelpInfo() {
return {
'alias': 'List, set, or show aliases (usage: alias [name=value])',
'unalias': 'Remove a user-defined alias (usage: unalias )',
};
}
load() {
try {
const data = localStorage.getItem(this.key);
return data ? { ...this.defaults, ...JSON.parse(data) } : { ...this.defaults };
} catch (e) { return { ...this.defaults }; }
}
save() {
const custom = {};
Object.keys(this.aliases).forEach(k => {
if (!this.defaults[k]) custom[k] = this.aliases[k];
});
localStorage.setItem(this.key, JSON.stringify(custom));
}
registerCommands() {
const self = this;
// Register management commands
this.commands['alias'] = (args) => self.handleAlias(args);
this.commands['unalias'] = (args) => self.handleUnalias(args);
// Register each alias as a delegating command
Object.keys(this.aliases).forEach(name => {
if (name === 'alias' || name === 'unalias') return;
const target = this.aliases[name];
this.commands[name] = (args) => {
if (this._aliasDepth >= this._aliasMaxDepth) { this.terminal.print(`alias: max depth (${this._aliasMaxDepth}) reached`, 'error'); return; }
this._aliasDepth++;
try { this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : '')); }
finally { this._aliasDepth--; }
};
});
}
handleAlias(args) {
if (!args[0]) {
// List all aliases
this.terminal.print('=== Aliases ===', 'info');
const keys = Object.keys(this.aliases);
if (keys.length === 0) {
this.terminal.print(' No aliases defined', 'warning');
} else {
keys.forEach(name => {
const mark = this.defaults[name] ? ' (built-in)' : '';
this.terminal.print(` ${name}=${this.aliases[name]}${mark}`);
});
}
this.terminal.print('Usage: alias <name>=<value>', 'info');
return;
}
// Parse name=value
const input = args.join(' ');
const eqIdx = input.indexOf('=');
if (eqIdx === -1) {
// Show specific alias value
const name = args[0];
if (this.aliases[name]) {
const mark = this.defaults[name] ? ' (built-in)' : '';
this.terminal.print(`${name}=${this.aliases[name]}${mark}`, 'info');
} else {
this.terminal.print(`Alias '${name}' not found`, 'error');
}
return;
}
const name = input.substring(0, eqIdx).trim();
const value = input.substring(eqIdx + 1).trim();
if (!name) return this.terminal.print('Invalid alias name', 'error');
if (!value) return this.terminal.print('Invalid alias value', 'error');
if (name === 'alias' || name === 'unalias') {
return this.terminal.print('Cannot alias reserved command name', 'error');
}
if (this.defaults[name]) {
return this.terminal.print(`Cannot alias built-in alias '${name}'`, 'warning');
}
this.aliases[name] = value;
this.save();
// Register the new alias as a command immediately
const target = value;
this.commands[name] = (args) => {
if (this._aliasDepth >= this._aliasMaxDepth) { this.terminal.print(`alias: max depth (${this._aliasMaxDepth}) reached`, 'error'); return; }
this._aliasDepth++;
try { this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : '')); }
finally { this._aliasDepth--; }
};
this.terminal.print(`Alias set: ${name}=${value}`, 'success');
}
handleUnalias(args) {
if (!args[0]) return this.terminal.print('Usage: unalias <name>', 'warning');
const name = args[0];
if (this.defaults[name]) {
return this.terminal.print(`Cannot unalias built-in alias '${name}'`, 'warning');
}
if (!this.aliases[name]) {
return this.terminal.print(`Alias '${name}' not found`, 'error');
}
delete this.aliases[name];
delete this.commands[name];
this.save();
this.terminal.print(`Alias removed: ${name}`, 'success');
}
}
if (typeof window.webx11 !== 'undefined' && window.webx11) {
webx11.moduleManager.registerModule(new AliasModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/alias.js');
}
/* === finder.js === */
class FinderModule {
constructor() {
this.commands = {};
this.terminal = null;
}
requirements() {
return { met: true, missing: [] };
}
getHelpInfo() {
return {
'find': 'Recursively search VFS files/directories by name (usage: find )',
};
}
registerCommands() {
this.commands['find'] = (args) => this.find(args);
}
find(args) {
if (!args[0]) return this.terminal.print('Usage: find โ recursively searches VFS files/dirs matching query', 'warning');
const query = args.join(' ').toLowerCase();
const results = [];
this._recurse(this.terminal.fs.root, [], query, results);
if (results.length === 0) return this.terminal.print(`No matches for "${query}"`, 'warning');
this.terminal.print(`=== Found ${results.length} match(es) for "${query}" ===`, 'info');
results.forEach(r => {
const icon = r.type === 'dir' ? '๐' : '๐';
this.terminal.print(` ${icon} ${r.path}`);
});
}
_recurse(node, pathParts, query, results) {
if (!node || !node.content) return;
for (const [name, child] of Object.entries(node.content)) {
const fullPath = '/' + [...pathParts, name].join('/');
if (name.toLowerCase().includes(query)) {
results.push({ name, path: fullPath, type: child.type || (child.content ? 'dir' : 'file') });
}
if (child.content && typeof child.content === 'object') {
this._recurse(child, [...pathParts, name], query, results);
}
}
}
}
if (typeof window.webx11 !== 'undefined' && window.webx11) {
webx11.moduleManager.registerModule(new FinderModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/finder.js');
}
/* === filemgr.js === */
// 2026-10-24: pure URL helpers, require-able so modules/test/test-download-url.js tests the
// shipped code instead of a copy of it. No DOM, no terminal, no module state.
function fmDetectSpaceName(pathname) {
try {
const parts = String(pathname || '').split('/').filter(Boolean);
const i = parts.indexOf('spaces');
return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
} catch (e) { return ''; }
}
function fmExtractSpaceNameFromUrl(url) {
if (!url) return '';
try {
const parts = new URL(url).pathname.split('/').filter(Boolean);
const i = parts.indexOf('spaces');
return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
} catch (e) { return ''; }
}
// Returns null when no space can be resolved โ never emits /spaces//.
function fmBuildShareUrl(space, path) {
if (!space) return null;
const clean = String(path || '').replace(/^\/+/, '');
if (!clean) return null;
return 'https://webx11.duckdns.org/spaces/' + space + '/' +
clean.split('/').map(encodeURIComponent).join('/');
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl };
}
class FileManagerModule {
constructor() {
this.commands = {};
this.commandsMeta = {
'files': { argType: 'file' }, 'preview': { argType: 'file' },
};
this.terminal = null;
this.currentPath = [];
this._overlay = null;
this._selectedFile = null;
this._multiSelect = new Set(); // v4.5.78: multi-select for batch ops
this._selectMode = false; // v4.6.35: tap-to-select mode (touch devices)
this._itemCount = 0; // items rendered in the current directory/filter
this._clipboard = { mode: null, paths: [] }; // 'copy' or 'cut'
this._searchFilter = '';
this._rawMode = false;
this._inlineHtml = false;
}
requirements() {
return { met: true, missing: [] };
}
getHelpInfo() {
return {
'files': 'files โ Open the file manager overlay with VFS directory listing, preview, encrypt, zip',
'preview': 'preview โ Preview a specific file',
};
}
registerCommands() {
this.commands['files'] = () => this._openOverlay();
this.commands['preview'] = (args) => this._previewCommand(args);
}
_previewCommand(args) {
if (!args[0]) return this.terminal.print('Usage: preview ', 'warning');
const path = args.join(' ');
this._openOverlay();
this._navigateToPathFromParts(path);
const content = this.terminal.fs.readFile(path);
if (content === null || content === false) {
this._renderPreviewError('File not found: ' + path);
return;
}
this._selectedFile = path;
this._renderPreview(path, content);
this._highlightSelected();
}
_navigateToPathFromParts(path) {
const parts = path.split('/').filter(p => p);
if (parts.length <= 1) {
this.currentPath = [];
return;
}
this.currentPath = parts.slice(0, -1);
}
_detectType(filePath) {
const ext = filePath.split('.').pop().toLowerCase();
if (!ext || ext === filePath) return 'other';
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico'];
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv'];
const audioExts = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
const codeExts = ['js', 'py', 'css', 'yaml', 'yml', 'toml'];
if (ext === 'md') return 'md';
if (imageExts.includes(ext)) return 'image';
if (videoExts.includes(ext)) return 'video';
if (audioExts.includes(ext)) return 'audio';
if (codeExts.includes(ext)) return 'code';
if (ext === 'html') return 'html';
if (ext === 'json') return 'json';
if (ext === 'csv') return 'csv';
if (ext === 'pdf') return 'pdf';
if (ext === 'svg') return 'svg';
if (ext === 'txt') return 'text';
return 'other';
}
_renderMarkdown(text) {
if (!text) return '';
try {
let html = text
.replace(/&/g, '&')
.replace(//g, '>');
// code blocks first
html = html.replace(/```([\s\S]*?)```/g, '$1
');
// inline code
html = html.replace(/`([^`]+)`/g, '$1');
// headers
html = html.replace(/^### (.+)$/gm, '$1
');
html = html.replace(/^## (.+)$/gm, '$1
');
html = html.replace(/^# (.+)$/gm, '$1
');
// bold
html = html.replace(/\*\*(.+?)\*\*/g, '$1');
// italic
html = html.replace(/\*(.+?)\*/g, '$1');
// links โ v4.5.67: sanitize URL to prevent javascript: scheme injection
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const safeUrl = /^(https?:\/\/|mailto:|\/|#)/.test(url) ? url : '#';
return `${text}`;
});
// horizontal rules
html = html.replace(/^---$/gm, '
');
// unordered lists
html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '$1');
// paragraphs - double newlines
html = html.replace(/\n\n/g, '
');
html = '
' + html + '
';
return html;
} catch (e) {
return '' + text.replace(/&/g, '&').replace(//g, '>') + '
';
}
}
_getPreviewHtml(path, content) {
if (content === null || content === false) {
return 'File not found: ' + this._escapeHtml(path) + '
';
}
const type = this._detectType(path);
const ext = path.split('.').pop().toLowerCase();
const fileName = path.split('/').pop() || path;
// Handle binary detection for media types
if (typeof content !== 'string') {
return 'Cannot preview: non-string content
';
}
const isLikelyBinary = content.length > 0 && !content.startsWith('\n') && !content.startsWith('\r') && !content.startsWith(' ') && !content.startsWith('<') && !content.startsWith('{') && !content.startsWith('[') && content.charCodeAt(0) < 32 && content.charCodeAt(0) !== 9 && content.charCodeAt(0) !== 10 && content.charCodeAt(0) !== 13;
const isTextContent = typeof content === 'string' && (content.startsWith('{') || content.startsWith('[') || content.startsWith('<') || content.startsWith('#') || /^[\w\s\n\r\t.,;:!?()\-=+*@#$%^&"'\/\\|`~]/.test(content.substring(0, 100)));
if (type === 'md' && isTextContent) {
return '' + this._renderMarkdown(content) + '
';
}
if (type === 'html') {
// Render HTML in an inline sandbox frame
const safeHtml = content
.replace(/