class FileSystemModule { constructor() { this.commands = {}; this.commandsMeta = { 'trash': { argType: 'file' }, 'ls-trash': { argType: 'file' }, 'upload': { argType: 'dir' }, 'vfs-export': { argType: 'file' }, 'vfs-import': { argType: 'file' }, 'download': { argType: 'file' }, }; this.terminal = null; } getHelpInfo() { return { 'ls': 'ls [path] [-la] — List files and directories', 'cat': 'cat — Display file contents', 'put': 'put — Create or overwrite a file', 'rm': 'rm — Remove a file (moved to trash); use -r/-rf for directories', 'trash': 'trash — Move a file to trash', 'trash-dir': 'trash-dir — Move a directory to trash', 'ls-trash': 'ls-trash — List items in trash', 'empty-trash': 'empty-trash — Permanently delete all trash items', 'mkdir': 'mkdir [-p] — Create a directory; -p creates parent paths', 'rmdir': 'rmdir — Remove a directory', 'mv': 'mv — Move file or directory', 'cp': 'cp — Copy file or directory', 'cd': 'cd — Change current directory; cd / to go to root', 'pwd': 'pwd — Print current working directory', 'find': 'find — Search files by name pattern', 'grep': 'grep [file] — Search file content for pattern', 'head': 'head [lines] — Show first N lines (default: 10)', 'tail': 'tail [-f] [-n N] — Show last N lines; -f follows live changes (Ctrl+C/Esc to stop)', 'sed': 'sed [file] — Stream editor; s/pat/rep/[g][i][p], /pat/d, Nd, $d; -i write in place; -n quiet mode', 'sort': 'sort — Sort lines of a file alphabetically', 'wc': 'wc — Count lines, words, and bytes', 'touch': 'touch — Create empty file or update timestamp', 'tree': 'tree — Display directory structure as a tree', 'du': 'du [path] — Estimate file/directory size', 'upload': 'upload — Upload a file from your device', 'download': 'download — Download a file to your device', 'vfs-export': 'vfs-export — Export the entire virtual filesystem as JSON', 'vfs-import': 'vfs-import — Import a previously exported VFS JSON file', 'session-save': 'session-save — Save current session (path + terminal history)', 'session-load': 'session-load — Load a saved session', 'session-list': 'session-list — List all saved sessions', 'session-new': 'session-new — Start a fresh session (auto-saves current, clears AI context)', 'session-delete': 'session-delete — Delete a saved session', }; } registerCommands() { const self = this; this.commands['ls'] = (args) => { const argv = args || []; // v4.6.104: the path may follow the flags — `ls -l /work` used to list the cwd // because only argv[0] was considered (MAINT-016). const flags = argv.filter(a => a.startsWith('-')); const target = argv.find(a => !a.startsWith('-')); 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) { // `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); if (showLong) { filtered.forEach(f => { const type = f.type === 'dir' ? 'd' : '-'; const perms = f.type === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'; const size = String(f.size || 0).padStart(6); this.terminal.print(type + perms + ' darkbot ' + size + ' ' + f.name); }); const total = filtered.reduce((s, f) => s + (f.size || 0), 0); this.terminal.print('total ' + filtered.length + ' (' + total + ' bytes)', 'text-muted'); } else { // v4.6.57: printClickable() writes straight to the DOM, so anything that // captures output (the AI run_command tool, the diagnostics sweep) saw nothing // and reported "directory empty" (packet §1b). Always emit a text line. const names = filtered.map(f => f.name + (f.type === 'dir' ? '/' : '')).join(' '); this.terminal.print(names); const capturing = !!(this.terminal._printCaptureStack && this.terminal._printCaptureStack.length); if (!capturing && typeof this.terminal.printClickable === 'function') this.terminal.printClickable(filtered); } }; this.commands['guides'] = (args) => { // v4.6.63: /guides/ was reachable by nobody — the AI writes guides there, and the // only way to find one was to guess its filename (packet §7). One command, newest first. let items = []; try { items = this.terminal.fs.readDir('/guides') || []; } catch (e) { items = []; } const docs = items .filter(f => f && f.type !== 'dir' && /\.(html?|md|txt)$/i.test(f.name)) .sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); if (!docs.length) { this.terminal.print('No guides in /guides/ yet.', 'info'); this.terminal.print(' Guides written by the AI land here — `guides` lists them, `preview ` renders HTML, `cat ` shows markdown.', 'text-muted'); return; } this.terminal.print(`📚 Guides (${docs.length}) — preview for HTML, cat for markdown`, 'header'); 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 [...]', '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 [...]', 'warning'); }; this.commands['echo'] = (args) => { if (!args || args.length === 0) { this.terminal.print(''); return; } const ri = args.indexOf('>'); if (ri >= 0) { const c = args.slice(0, ri).join(' '); const fn = args.slice(ri + 1).join(' '); if (fn) { this.terminal.fs.writeFile(fn, c); this.terminal.print('Wrote ' + c.length + ' bytes to ' + fn, 'success'); } return; } this.terminal.print(args.join(' ')); }; this.commands['put'] = (args) => { if(args.length < 2) return this.terminal.print('Usage: put ', 'warning'); const name = args[0]; const content = args.slice(1).join(' '); this.terminal.fs.writeFile(name, content); this.terminal.print('Created/updated: ' + name + ' (' + content.length + ' bytes)', 'success'); }; this.commands['rm'] = (args) => { if(!args[0]) return this.terminal.print('Usage: rm ', 'warning'); if(args[0] === '-r' || args[0] === '-rf') { const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r ', 'warning'); // Use trash for recursive dir removal too if (this.terminal.fs.trashDir(t)) this.terminal.print('Moved to trash: ' + t, 'success'); else this.terminal.print('rm: ' + t + ': No such file or directory', 'error'); return; } if(this.terminal.fs.deleteFile(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('rm: ' + args[0] + ': No such file', 'error'); }; this.commands['trash'] = (args) => { const fs = this.terminal.fs; if(!args[0]) return this.terminal.print('Usage: trash ', 'warning'); const node = fs.readFile(args[0]) !== null ? { type: 'file' } : fs.readDir(args[0]); if (!node) return this.terminal.print('trash: ' + args[0] + ': No such file', 'error'); if (node.type === 'dir') { if (fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('trash: ' + args[0] + ': Failed', 'error'); } else { const content = fs.readFile(args[0]); if (content !== null) { fs.trashFile(args[0], { type: 'file', content, modified: new Date() }); this.terminal.print('Moved to trash: ' + args[0], 'success'); } else { this.terminal.print('trash: ' + args[0] + ': No such file', 'error'); } } }; this.commands['trash-dir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: trash-dir ', 'warning'); if (this.terminal.fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('trash-dir: ' + args[0] + ': No such directory', 'error'); }; this.commands['ls-trash'] = () => { const fs = this.terminal.fs; const items = fs.listTrash ? fs.listTrash() : []; if (!items.length) return this.terminal.print('Trash is empty', 'info'); this.terminal.print('Trash items:', 'info'); items.forEach(it => { const date = new Date(it.ts).toLocaleString(); this.terminal.print(` ${it.name} [${it.type}] ${date}`); }); }; this.commands['empty-trash'] = () => { const fs = this.terminal.fs; const removed = fs.emptyTrash ? fs.emptyTrash(0) : 0; this.terminal.print(`Emptied trash: ${removed} item(s) permanently deleted`, removed > 0 ? 'warning' : 'info'); }; this.commands['mkdir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: mkdir ', 'warning'); if (args[0] === '-p') { if(!args[1]) return this.terminal.print('Usage: mkdir -p ', 'warning'); if(this.terminal.fs.mkdirRecursive(args[1])) this.terminal.print('Created directories: ' + args[1], 'success'); else this.terminal.print('mkdir: cannot create ' + args[1], 'error'); return; } if(this.terminal.fs.mkdir(args[0])) this.terminal.print('Created directory: ' + args[0], 'success'); else this.terminal.print('mkdir: cannot create ' + args[0] + ': File exists', 'error'); }; this.commands['rmdir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: rmdir ', 'warning'); if (this.terminal.fs.deleteDirRecursive(args[0])) this.terminal.print('Removed directory: ' + args[0], 'success'); else this.terminal.print('rmdir: ' + args[0] + ': No such directory', 'error'); }; this.commands['mv'] = (args) => { if(!args[0] || !args[1]) return this.terminal.print('Usage: mv ', 'warning'); const fs = this.terminal.fs; const d = fs._resolvePath(args[1]); const dn = d && d.name ? d.dir.content[d.name] : null; if (dn && dn.type === 'dir' && !dn._virtual) return this.terminal.print('mv: ' + args[1] + ': is a directory — refusing to overwrite it', 'error'); if (fs.mv(args[0], args[1])) this.terminal.print('Moved: ' + args[0] + ' to ' + args[1], 'success'); else this.terminal.print('mv: ' + args[0] + ': No such file or directory', 'error'); }; this.commands['cp'] = (args) => { if(!args[0] || !args[1]) return this.terminal.print('Usage: cp ', 'warning'); const fs = this.terminal.fs; const d = fs._resolvePath(args[1]); const dn = d && d.name ? d.dir.content[d.name] : null; if (dn && dn.type === 'dir' && !dn._virtual) return this.terminal.print('cp: ' + args[1] + ': is a directory — refusing to overwrite it', 'error'); if (fs.cp(args[0], args[1])) this.terminal.print('Copied: ' + args[0] + ' to ' + args[1], 'success'); else this.terminal.print('cp: ' + args[0] + ': No such file or directory', 'error'); }; this.commands['cd'] = (args) => { if (!args[0] || args[0] === '/') { this.terminal.fs.currentPath = []; this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); return; } const segs = this.terminal.fs._toAbsoluteSegments(args[0]); if (segs === null) { this.terminal.print('cd: ' + args[0] + ': No such file or directory', 'error'); return; } const dir = this.terminal.fs._resolveDir(args[0]); if (dir) { this.terminal.fs.currentPath = segs; this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); } else { this.terminal.print('cd: ' + args[0] + ': Not a directory', 'error'); } }; this.commands['pwd'] = () => { this.terminal.print('/' + this.terminal.fs.currentPath.join('/')); }; this.commands['find'] = (args) => { if (!args[0]) return this.terminal.print('Usage: find ', 'warning'); const results = this.terminal.fs.findFiles ? this.terminal.fs.findFiles(args[0].toLowerCase()) : []; if (results.length === 0) return this.terminal.print('No matches for ' + args[0], 'info'); results.forEach(r => this.terminal.print('./' + r)); this.terminal.print('Found ' + results.length + ' match(es)', 'info'); }; this.commands['grep'] = (args) => { // 2026-10-21: accept the common GNU flags instead of treating them as the pattern. // `grep -n "x" file` used to search for the literal "-n" inside a file named "x" // (6/6 syntax variants failed for the agent), while plain grep already prints // "line: text" for every hit. const opt = { count: false, invert: false, files: false, ignoreCase: true }; let ai = 0; while (ai < args.length && /^-[a-zA-Z]+$/.test(args[ai])) { const f = args[ai].slice(1); if (f.indexOf('c') !== -1) opt.count = true; if (f.indexOf('v') !== -1) opt.invert = true; if (f.indexOf('l') !== -1) opt.files = true; if (f.indexOf('i') !== -1) opt.ignoreCase = true; ai++; } const pattern = args[ai]; const target = args[ai + 1]; if (!pattern) return this.terminal.print('Usage: grep [-n] [-i] [-c] [-l] [filename ...]', 'warning'); // v4.6.64: an invalid pattern is user input, not a system fault. It used to throw // out of the command handler into the error ledger, polluting the counter that // packet §2 says nobody can trust (packet §8). let re; try { re = new RegExp(pattern, opt.ignoreCase ? 'i' : ''); } catch (e) { this.terminal.print('grep: invalid pattern: ' + pattern + ' (' + e.message + ')', 'warning'); return; } const hits = (line) => (opt.invert ? !re.test(line) : re.test(line)); // v4.6.66: every path argument is searched. `grep x a b` used to read only `a` and // exit clean — a silent false negative (packet §1). const grepFiles = args.slice(ai + 1).filter(Boolean); if (grepFiles.length) { const multi = grepFiles.length > 1; let grand = 0; let readFiles = 0; grepFiles.forEach(file => { const c = this.terminal.fs.readFile(file); if (c === null || c === undefined) { this.terminal.print('grep: ' + file + ': No such file', 'error'); return; } readFiles++; let count = 0; c.split('\n').forEach((line, i) => { if (!hits(line)) return; count++; if (opt.count || opt.files) return; this.terminal.print(multi ? (file + ':' + (i + 1) + ': ' + line) : ((i + 1) + ': ' + line)); }); grand += count; if (opt.files) { if (count) this.terminal.print(file); } else if (opt.count) this.terminal.print(multi ? (file + ':' + count) : String(count)); else if (!count && !multi) this.terminal.print('(no matches)', 'info'); }); if (!opt.count && !opt.files) { if (grand === 0) this.terminal.print('(no matches)', 'info'); else this.terminal.print(grand + ' match(es)' + (multi ? ' in ' + readFiles + ' file(s)' : ''), 'text-muted'); } } else { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return this.terminal.print('grep: cannot search', 'error'); let total = 0; Object.keys(dir.content).forEach(name => { const node = dir.content[name]; if (node.type === 'file') { (node.content || '').split('\n').forEach((line, i) => { if (!hits(line)) return; total++; if (!opt.count && !opt.files) this.terminal.print(name + ':' + (i + 1) + ': ' + line); }); } }); if (opt.files && total) this.terminal.print(dir.name || '.'); if (total === 0) this.terminal.print('(no matches)', 'info'); else if (opt.count) this.terminal.print(String(total)); else if (!opt.files) this.terminal.print(total + ' total match(es)', 'text-muted'); } }; this.commands['head'] = (args) => { // 2026-10-21: accept `head -40 f`, `head -n 40 f` and `head f 40`. // v4.6.66: every path argument, not just the first (packet §1). let n = 10; const headFiles = []; for (let i = 0; i < (args || []).length; i++) { const a = args[i]; if (/^-\d+$/.test(a)) n = parseInt(a.slice(1), 10); else if (a === '-n') { const v = parseInt(args[i + 1], 10); if (!isNaN(v)) { n = v; i++; } } else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } else if (headFiles.length && /^\d+$/.test(a)) n = parseInt(a, 10); // head f 40 else headFiles.push(a); } // 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] [...] [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); if (c === null || c === undefined) return this.terminal.print('head: ' + file + ': No such file', 'error'); if (headMulti) this.terminal.print('==> ' + file + ' <==', 'text-muted'); this.terminal.print(c.split('\n').slice(0, Math.max(1, n)).join('\n')); }); }; this.commands['tail'] = (args) => { const self = this; let follow = false, n = 10; const positional = []; for (let i = 0; i < (args || []).length; i++) { const a = args[i]; if (a === '-f' || a === '--follow') follow = true; else if (a === '-n') { const v = args[++i]; if (v === undefined || !/^\d+$/.test(v)) return this.terminal.print('tail: -n requires a number', 'error'); n = parseInt(v, 10); } else if (/^-\d+$/.test(a)) n = parseInt(a.slice(1), 10); else if (a === '-c' || a === '-q' || a === '-v' || a === '--pid') return this.terminal.print('tail: option ' + a + ' not supported', 'error'); else if (a.startsWith('-')) return this.terminal.print('tail: invalid option: ' + a, 'error'); else positional.push(a); } // v4.6.66: every path argument (packet §1). A numeric second positional is still // the legacy `tail `, not a filename. const tailFiles = positional.filter((p, i) => !(i > 0 && /^\d+$/.test(p))); // 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] [...] — 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; if (follow && tailMulti) { this.terminal.print('tail: -f needs a single file — following ' + tailFiles[0] + ' only', 'warning'); shownFiles = [tailFiles[0]]; } let firstContent = null; shownFiles.forEach(f2 => { const cc = this.terminal.fs.readFile(f2); if (cc === null || cc === undefined) return this.terminal.print('tail: ' + f2 + ': No such file', 'error'); if (shownFiles.length > 1) this.terminal.print('==> ' + f2 + ' <==', 'text-muted'); // 2026-10-21: a trailing newline produced an empty last element, so `tail -n 1` // printed a blank line instead of the final line. const _all = cc.split('\n'); if (_all.length > 1 && _all[_all.length - 1] === '') _all.pop(); this.terminal.print(_all.slice(-n).join('\n')); if (firstContent === null) firstContent = cc; }); const file = tailFiles[0]; const c = firstContent === null ? '' : firstContent; if (!follow) return; if (c === '') return; // v4.6.66: the only file was unreadable — nothing to follow // AI tool calls cannot press Ctrl+C — auto-downgrade to a bounded read instead of a live timer if (this.terminal.security && this.terminal.security._aiToolDepth > 0) { return this.terminal.print('tail: follow mode (-f) is interactive-only; showing last lines instead (use tail -n ' + Math.max(n, 50) + ' for more)', 'warning'); } // stop any previous follow before starting a new one if (this.terminal._activeFollow && this.terminal._activeFollow.interval) clearInterval(this.terminal._activeFollow.interval); this.terminal._activeFollow = null; let offset = c.length; const interval = setInterval(() => { const cur = self.terminal.fs.readFile(file); if (cur === null || cur === undefined) { self.terminal.stopFollow('tail: ' + file + ': file removed — follow stopped'); return; } if (cur.length < offset) { self.terminal.print('tail: ' + file + ': file truncated — following from start', 'warning'); self.terminal.print(cur); offset = cur.length; return; } if (cur.length > offset) { self.terminal.print(cur.slice(offset)); offset = cur.length; } }, 1500); this.terminal._activeFollow = { interval, file }; this.terminal.print('tail: following ' + file + ' (Ctrl+C or Esc to stop)', 'text-muted'); }; this.commands['sed'] = (args) => { if (!args || args.length === 0) { return this.terminal.print('Usage: sed [file] — expressions: s/pat/rep/[g][i][p], /pat/d, Nd, $d; flags: -i in place, -n quiet', 'warning'); } let inPlace = false, quiet = false, expr = null, file = null; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '-i') inPlace = true; else if (a === '-n') quiet = true; else if (expr === null) expr = a; else if (file === null) file = a; else return this.terminal.print('sed: too many arguments: ' + a, 'error'); } if (!expr) return this.terminal.print('sed: missing expression', 'error'); let content; if (file) { content = this.terminal.fs.readFile(file); if (content === null || content === undefined) return this.terminal.print('sed: ' + file + ': No such file', 'error'); } else if (this.terminal._pipeInput !== null && this.terminal._pipeInput !== undefined) { content = this.terminal._pipeInput; // piped input: cat x | sed s/a/b/ } else { return this.terminal.print('sed: missing file operand (no piped input)', 'error'); } let subst = null, delMatch = null, delLine = null; // 2026-10-21: `sed -n '10,40p' file` and `sed -n '7p' file` had no parser, so the // most common "show me lines N–M" idiom failed with an unsupported-expression error. const _range = /^(\d+)(?:,(\d+))?p$/.exec(expr); if (_range) { const _a = parseInt(_range[1], 10); const _b = _range[2] ? parseInt(_range[2], 10) : _a; const _lines = content.split('\n'); this.terminal.print(_lines.slice(Math.max(0, _a - 1), Math.max(_a, _b)).join('\n')); return; } if (expr.startsWith('s/') && expr.length > 2) { const parts = []; let cur = ''; for (let i = 2; i < expr.length; i++) { const ch = expr[i]; if (ch === '\\' && expr[i + 1] === '/') { cur += '/'; i++; } else if (ch === '/') { parts.push(cur); cur = ''; } else cur += ch; } parts.push(cur); if (parts.length < 2 || parts.length > 3) return this.terminal.print('sed: malformed expression: ' + expr, 'error'); const flags = parts[2] || ''; if ([...flags].some(c => !'gip'.includes(c))) return this.terminal.print('sed: unsupported flags: ' + flags, 'error'); subst = { pat: parts[0], rep: parts[1], flags }; } else if (/^\/(.+)\/d$/.test(expr)) { delMatch = expr.slice(1, -2); } else if (/^(\d+|\$)d$/.test(expr)) { delLine = expr.slice(0, -1); } else { return this.terminal.print('sed: unsupported expression: ' + expr, 'error'); } let lines = content.split('\n'); let trailingNL = false; if (lines.length && lines[lines.length - 1] === '') { trailingNL = true; lines.pop(); } const outLines = []; let changed = 0; if (subst) { let re; try { re = new RegExp(subst.pat, (subst.flags.includes('i') ? 'i' : '') + (subst.flags.includes('g') ? 'g' : '')); } catch (e) { return this.terminal.print('sed: invalid regex: ' + e.message, 'error'); } for (const line of lines) { const after = line.replace(re, subst.rep); if (after !== line) changed++; if (quiet) { if (subst.flags.includes('p') && after !== line) this.terminal.print(after); } else { outLines.push(after); } } } else if (delMatch !== null) { let re; try { re = new RegExp(delMatch); } catch (e) { return this.terminal.print('sed: invalid regex: ' + e.message, 'error'); } for (const line of lines) { if (re.test(line)) changed++; else outLines.push(line); } } else { const idx = delLine === '$' ? lines.length - 1 : parseInt(delLine, 10) - 1; if (idx < 0 || idx >= lines.length) return this.terminal.print('sed: line number out of range: ' + delLine, 'error'); for (let i = 0; i < lines.length; i++) { if (i === idx) changed++; else outLines.push(lines[i]); } } if (inPlace) { if (!file) return this.terminal.print('sed: -i requires a file operand', 'error'); this.terminal.fs.writeFile(file, outLines.join('\n') + (trailingNL ? '\n' : '')); this.terminal.print('sed: updated ' + file + ' (' + changed + ' line' + (changed === 1 ? '' : 's') + ' changed)', 'success'); } else if (quiet) { if (changed === 0) this.terminal.print('(no changes)', 'text-muted'); } else { this.terminal.print(outLines.join('\n')); } }; this.commands['sort'] = (args) => { if(!args[0]) return this.terminal.print('Usage: sort ', 'warning'); const c = this.terminal.fs.readFile(args[0]); if(c === null || c === undefined) return this.terminal.print('sort: ' + args[0] + ': No such file', 'error'); c.split('\n').sort().forEach(l => this.terminal.print(l)); }; this.commands['wc'] = (args) => { // 2026-10-21: accept -l / -w / -c (previously ANY flag was read as a filename, // so `wc -l file` answered "wc: -l: No such file"). // v4.6.58: multiple files. `wc -c a b` used to print only `a` and exit clean — // a silent false negative (packet §1). const want = { l: false, w: false, c: false }; const files = []; (args || []).forEach(a => { if (/^-[lwc]+$/.test(a)) a.slice(1).split('').forEach(k => { want[k] = true; }); else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } else files.push(a); }); // 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] [...]', '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) { this.terminal.print('wc: ' + file + ': No such file', 'error'); return; } const r = { file: file, 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); }); if (!rows.length) return; const multi = rows.length > 1; const fmt = (r, name) => { const out = []; 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))) && name !== null) out.push(name); return out.join(' '); }; rows.forEach(r => this.terminal.print(fmt(r, r.file))); if (multi) this.terminal.print(fmt(total, 'total')); }; this.commands['touch'] = (args) => { if(!args[0]) return this.terminal.print('Usage: touch ', 'warning'); const e = this.terminal.fs.readFile(args[0]); if (e === null || e === undefined) { const segs = this.terminal.fs._toAbsoluteSegments(args[0]); if (segs) { const dirPath = segs.slice(0, -1).join('/') || '.'; const name = segs[segs.length - 1]; this.terminal.fs.mkdirRecursive(dirPath); } this.terminal.fs.writeFile(args[0], ''); this.terminal.print('Created: ' + args[0], 'success'); } else { this.terminal.print('Touched: ' + args[0], 'success'); } }; this.commands['tree'] = () => { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return this.terminal.print('(no directory)', 'warning'); const lines = []; this._printTree(dir, '', lines); lines.forEach(l => this.terminal.print(l)); }; this.commands['du'] = (args) => { const t = args && args[0] ? args[0] : '.'; if (t === '.') { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return; let total = 0; Object.keys(dir.content).forEach(k => { const node = dir.content[k]; total += node && typeof node.content === 'string' ? node.content.length : 0; }); this.terminal.print(total + '\t' + t); } else { const c = this.terminal.fs.readFile(t); if (c === null || c === undefined) return; this.terminal.print(c.length + '\t' + t); } }; this.commands['upload'] = () => { document.getElementById('file-upload-input').click(); }; this.commands['download'] = async (args) => { if(!args[0]) return this.terminal.print('Usage: download ', 'warning'); const fname = args[0]; const content = this.terminal.fs.readFile(fname); if(content === null || content === undefined) { return this.terminal.print('download: ' + fname + ': No such file', 'error'); } // Push to WebDAV first — this is the only path to a real file download on Telegram WebView // because browser-based file downloads are blocked in WebViews let downloadUrl = null; if (this.terminal.moduleManager && this.terminal.moduleManager.modules.WebDAVModule) { const wdav = this.terminal.moduleManager.modules.WebDAVModule; if (wdav.config && wdav.config.url) { try { // Make sure /downloads/ dir exists try { await wdav._ensureDir('/downloads'); } catch(e) {} const resp = await wdav._webdavRequest('PUT', '/downloads/' + fname, { body: content, headers: { 'Content-Type': 'application/octet-stream' } }); if (resp.ok || resp.status === 201 || resp.status === 204) { // Build the public URL the user can tap const baseUrl = wdav.config.url.replace(/\/+$/, ''); const hostMatch = baseUrl.match(/^(https?:\/\/[^\/]+)/); const host = hostMatch ? hostMatch[1] : 'https://webx11.duckdns.org'; // Build the public URL the user can tap. // The URL must use the space the PUT actually went to // (config.url = the logged-in user's space). _spaceName is // derived from the page path (/spaces/newos4/…) and is wrong // for users downloading into their own space (2026-10-21). const urlSpace = (typeof wdav._resolveSpaceName === 'function' && wdav._resolveSpaceName()) || (wdav._extractSpaceNameFromUrl && wdav._extractSpaceNameFromUrl(wdav.config.url)) || wdav._spaceName; downloadUrl = host + '/spaces/' + urlSpace + '/downloads/' + encodeURIComponent(fname); this.terminal.print('Uploaded to: ' + downloadUrl, 'success'); } else { this.terminal.print('WebDAV push failed: HTTP ' + resp.status, 'warning'); } } catch (e) { this.terminal.print('WebDAV push error: ' + e.message, 'warning'); } } else { this.terminal.print('WebDAV not configured. Run: webdav-config', 'warning'); } } // Build a visible anchor link — tap = real user gesture = native browser download bar const size = content.length; const sizeStr = size > 1024 ? (size / 1024).toFixed(1) + ' KB' : size + ' B'; const line = document.createElement('div'); line.className = 'output-line'; line.style.cssText = 'padding:8px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;'; if (downloadUrl) { line.innerHTML = '⬇ ' + fname + ' (' + sizeStr + ') ' + '⬇ Tap to download'; } else { line.innerHTML = '⬇ ' + fname + ' (' + sizeStr + ') — run webdav-config first to enable downloads'; } this.terminal.outputContainer.appendChild(line); this.terminal.scrollToBottom(); }; this.commands['vfs-export'] = () => { const data = this.terminal.fs.root; const json = JSON.stringify(data, null, 2); const blob = new Blob([json], {type: 'application/json'}); const sizeMB = blob.size / (1024 * 1024); if (sizeMB > 5) { this.terminal.print('Warning: VFS size is ' + sizeMB.toFixed(1) + ' MB — large download may take time', 'warning'); } const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'webx11-filesystem-' + dateStr + '.json'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); this.terminal.print('Exported VFS to webx11-filesystem-' + dateStr + '.json', 'success'); }; this.commands['vfs-size'] = () => { try { const json = JSON.stringify(this.terminal.fs.root); const totalSize = (json.length / 1024 / 1024).toFixed(2); const root = this.terminal.fs.root; const sizeMap = []; function walk(node, path) { if (!node || !node.content) return; for (const [name, child] of Object.entries(node.content)) { const fullPath = path + '/' + name; if (child.type === 'file') { const size = (child.content ? child.content.length : 0); sizeMap.push({ path: fullPath, size, sizeKB: (size / 1024).toFixed(1) }); } else if (child.type === 'dir') { walk(child, fullPath); } } } walk(root, ''); sizeMap.sort((a, b) => b.size - a.size); const totalFiles = sizeMap.length; const top5 = sizeMap.slice(0, 5); this.terminal.print('📊 VFS Size Report', 'text-muted'); this.terminal.print(' Total: ' + totalSize + ' MB across ' + totalFiles + ' files'); this.terminal.print(' Largest:'); top5.forEach((f, i) => { this.terminal.print(' ' + (i + 1) + '. ' + f.path + ' — ' + f.sizeKB + ' KB'); }); // Show system dir sizes const sysDirs = ['/_snapshots', '/_sessions', '/.trash']; for (const dir of sysDirs) { const dirNode = this.terminal.fs.readDir(dir); if (dirNode) { const cnt = dirNode.length; // estimate dir size const dirSize = JSON.stringify(dirNode).length; this.terminal.print(' ' + dir + ': ' + cnt + ' files, ~' + (dirSize / 1024).toFixed(1) + ' KB', dirSize > 102400 ? 'warning' : 'text-muted'); } } } catch (e) { this.terminal.print('vfs-size error: ' + e.message, 'error'); } }; // Session commands (save/load/export/import) this.commands['session-save'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-save ', 'warning'); const name = args[0].replace(/[^a-zA-Z0-9_-]/g, '_'); // v2: metadata-only — VFS is persisted independently in localStorage const manifest = { type: 'session', version: 2, savedAt: new Date().toISOString(), currentPath: this.terminal.fs.currentPath, terminalLines: this.terminal._historyBuffer ? this.terminal._historyBuffer.slice(-50) : [] }; const filePath = '_sessions/' + name + '.json'; this.terminal.fs.mkdirRecursive('_sessions'); this.terminal.fs.writeFile(filePath, JSON.stringify(manifest)); this.terminal.print('Session saved: ' + name + ' (' + (manifest.terminalLines.length || 0) + ' history lines)', 'success'); }; this.commands['session-load'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-load ', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; const content = this.terminal.fs.readFile(filePath); if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); try { const session = JSON.parse(content); if (!session || session.type !== 'session') { return this.terminal.print('Invalid session file: ' + args[0], 'error'); } if (session.version === 2) { // v2: metadata-only restore — keep existing VFS, just restore path + terminal history this.terminal.fs.currentPath = session.currentPath || []; if (session.terminalLines && Array.isArray(session.terminalLines) && this.terminal._historyBuffer) { this.terminal._historyBuffer = session.terminalLines.slice(-50); // Re-render last few lines for context this.terminal.outputContainer.innerHTML = ''; session.terminalLines.forEach(line => { const text = typeof line === 'string' ? line : (line.l || ''); const type = typeof line === 'object' ? (line.t || '') : ''; this.terminal.print(text, type); }); } } else { // v1: full VFS restore (legacy) if (!session.fsRoot) return this.terminal.print('Invalid session file', 'error'); this.terminal.fs.root = session.fsRoot; this.terminal.fs.currentPath = session.currentPath || []; } this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); this.terminal.print('Session loaded: ' + args[0] + ' (' + new Date(session.savedAt).toLocaleString() + ')', 'success'); this.terminal.executeCommand('ls'); } catch (e) { this.terminal.print('Failed to load session: ' + e.message, 'error'); } }; this.commands['session-list'] = () => { this.terminal.fs.mkdirRecursive('_sessions'); const files = this.terminal.fs.ls('_sessions'); if (!files || files.length === 0) return this.terminal.print('No saved sessions', 'info'); this.terminal.print('Saved sessions:', 'text-muted'); files.forEach(f => { if (f.type === 'file' && f.name.endsWith('.json')) { try { const content = JSON.parse(this.terminal.fs.readFile('_sessions/' + f.name)); const label = f.name.replace('.json', ''); const date = content.savedAt ? new Date(content.savedAt).toLocaleString() : 'unknown'; if (content.version === 2) { const lines = content.terminalLines ? content.terminalLines.length : 0; this.terminal.print(' ' + label + ' (' + lines + ' history lines, ' + date + ')'); } else { const fileCount = content.fsRoot ? Object.keys(content.fsRoot.content || {}).length : 0; this.terminal.print(' ' + label + ' (' + fileCount + ' items, ' + date + ')'); } } catch (e) { this.terminal.print(' ' + f.name.replace('.json', '') + ' (corrupt)'); } } }); }; this.commands['session-delete'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-delete ', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; if (this.terminal.fs.deleteFile(filePath)) { this.terminal.print('Session deleted: ' + args[0], 'success'); } else { this.terminal.print('Session not found: ' + args[0], 'error'); } }; this.commands['session-new'] = () => { // Auto-save current session, clear AI context, fresh terminal this.terminal._autoSaveSession(); try { localStorage.removeItem('webx11_ai_context'); } catch(e) {} // Clear terminal output if (this.terminal.outputContainer) this.terminal.outputContainer.innerHTML = ''; if (this.terminal._historyBuffer) this.terminal._historyBuffer = []; // Clear AI conversation context const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; if (aiMod && aiMod.clearContext) aiMod.clearContext(); this.terminal.print('✨ New session started — context cleared', 'success'); }; this.commands['session-export'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-export ', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; const content = this.terminal.fs.readFile(filePath); if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); const blob = new Blob([content], {type: 'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'webx11-session-' + args[0] + '.json'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); this.terminal.print('Exported session: ' + args[0], 'success'); }; this.commands['session-import'] = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { try { const session = JSON.parse(evt.target.result); if (!session || session.type !== 'session') { this.terminal.print('Invalid session file (not a valid session export)', 'error'); return; } if (session.version === 2) { // v2: metadata-only — keep existing VFS, restore path this.terminal.fs.currentPath = session.currentPath || []; if (session.terminalLines && Array.isArray(session.terminalLines) && this.terminal._historyBuffer) { this.terminal._historyBuffer = session.terminalLines.slice(-50); this.terminal.outputContainer.innerHTML = ''; session.terminalLines.forEach(line => { const text = typeof line === 'string' ? line : (line.l || ''); const type = typeof line === 'object' ? (line.t || '') : ''; this.terminal.print(text, type); }); } } else { if (!session.fsRoot) { this.terminal.print('Invalid session file (not a valid session export)', 'error'); return; } this.terminal.fs.root = session.fsRoot; this.terminal.fs.currentPath = session.currentPath || []; } this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); this.terminal.print('Session imported: ' + file.name + ' (' + (session.savedAt ? new Date(session.savedAt).toLocaleString() : 'unknown date') + ')', 'success'); this.terminal.executeCommand('ls'); } catch (err) { this.terminal.print('Failed to import session: ' + err.message, 'error'); } }; reader.readAsText(file); }; input.click(); }; this.commands['vfs-import'] = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { try { const data = JSON.parse(evt.target.result); if (!data || !data.type) { this.terminal.print('Invalid VFS file', 'error'); return; } if (this.terminal.fs.restoreFromExport(data)) { this.terminal.updatePrompt(); this.terminal.print('VFS restored from: ' + file.name, 'success'); this.terminal.executeCommand('ls'); } else { this.terminal.print('Failed to restore VFS', 'error'); } } catch (err) { this.terminal.print('Failed to import VFS: ' + err.message, 'error'); } }; reader.readAsText(file); }; input.click(); }; } _printTree(dir, prefix, lines) { const names = Object.keys(dir.content || {}).sort(); names.forEach((name, i) => { const isLast = i === names.length - 1; const node = dir.content[name]; const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 '; const label = node.type === 'dir' ? name + '/' : name; lines.push(prefix + connector + label); if (node.type === 'dir') { this._printTree(node, prefix + (isLast ? ' ' : '\u2502 '), lines); } }); } }