diff options
31 files changed, 3764 insertions, 292 deletions
diff --git a/CryptoGzipModule-draft.js b/CryptoGzipModule-draft.js new file mode 100644 index 0000000..2c1e45f --- /dev/null +++ b/CryptoGzipModule-draft.js @@ -0,0 +1,315 @@ +/** + * CryptoGzipModule — Draft for Dev Agent + * + * Commands: + * gzip <path> → creates <path>.gz + * gunzip <path.gz> → restores <path> + * encrypt <path> → prompts passphrase → creates <path>.enc + * decrypt <path.enc> → prompts passphrase → restores <path> + * + * Wires into WebX11 module system. Copy into modules/ext/ or modules/tools/. + */ + +class CryptoGzipModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._passphraseCache = new Map(); // session-only, never persisted + } + + requirements() { + const hasCrypto = !!(window.crypto && crypto.subtle); + const hasCompression = !!(window.CompressionStream); + const missing = []; + if (!hasCrypto) missing.push('Web Crypto API not available (requires secure context)'); + if (!hasCompression) missing.push('CompressionStream not available (requires Chromium-based browser or recent Firefox)'); + return { met: missing.length === 0, missing }; + } + + getHelpInfo() { + return { + 'gzip': 'gzip <path> — Compress a VFS file to .gz', + 'gunzip': 'gunzip <path.gz> — Decompress a .gz file in VFS', + 'encrypt':'encrypt <path> — AES-256-GCM encrypt a VFS file (interactive passphrase)', + 'decrypt':'decrypt <path.enc> — Decrypt a .enc file (interactive passphrase)', + }; + } + + init(terminal) { + this.terminal = terminal; + this.commands = { + 'gzip': (args) => this._cmdGzip(args), + 'gunzip': (args) => this._cmdGunzip(args), + 'encrypt':(args) => this._cmdEncrypt(args), + 'decrypt':(args) => this._cmdDecrypt(args), + }; + } + + // ============================================================ + // VFS WIRE POINTS — Dev Agent: replace with real VFS calls + // ============================================================ + + async _vfsRead(path) { + // WIRE: return await this.terminal.vfs.readFile(path, 'string'); + // OR: return window.webx11_vfs.read(path); + throw new Error('_vfsRead not wired'); + } + + async _vfsWrite(path, content) { + // WIRE: await this.terminal.vfs.writeFile(path, content); + // content is a string (base64 for binary-ish data) + throw new Error('_vfsWrite not wired'); + } + + async _vfsExists(path) { + // WIRE: return this.terminal.vfs.exists(path); + throw new Error('_vfsExists not wired'); + } + + async _vfsDelete(path) { + // WIRE: await this.terminal.vfs.delete(path); + throw new Error('_vfsDelete not wired'); + } + + // ============================================================ + // Prompt Utility + // ============================================================ + + _promptPassphrase(message = 'Enter passphrase: ') { + return new Promise((resolve) => { + // WIRE: adapt to WebX11 terminal prompt + // Option A: if terminal has hidden prompt: + // this.terminal.prompt(message, { hidden: true }).then(resolve); + // Option B: temporary override: + const savedHandler = this.terminal.onCommand; + this.terminal.print(message); + this.terminal.onCommand = (line) => { + this.terminal.onCommand = savedHandler; + resolve(line); + }; + }); + } + + _confirmPrompt(message) { + return new Promise((resolve) => { + this.terminal.print(message + ' (yes/no): '); + const savedHandler = this.terminal.onCommand; + this.terminal.onCommand = (line) => { + this.terminal.onCommand = savedHandler; + resolve(line.trim().toLowerCase() === 'yes'); + }; + }); + } + + // ============================================================ + // GZIP + // ============================================================ + + async _cmdGzip(args) { + const path = args[0]; + if (!path) throw new Error('Usage: gzip <path>'); + + const exists = await this._vfsExists(path); + if (!exists) throw new Error(`File not found: ${path}`); + + const outPath = path + '.gz'; + if (await this._vfsExists(outPath)) { + const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); + if (!ok) return 'Cancelled.'; + } + + const content = await this._vfsRead(path); + const compressed = await this._gzipString(content); + await this._vfsWrite(outPath, compressed); + await this._vfsDelete(path); + + const ratio = (compressed.length / Math.max(content.length, 1)).toFixed(2); + return `gzipped: ${path} → ${outPath} (${content.length} → ${compressed.length} chars, ratio ${ratio})`; + } + + async _cmdGunzip(args) { + const path = args[0]; + if (!path || !path.endsWith('.gz')) throw new Error('Usage: gunzip <path.gz>'); + + const exists = await this._vfsExists(path); + if (!exists) throw new Error(`File not found: ${path}`); + + const outPath = path.replace(/\.gz$/, ''); + if (await this._vfsExists(outPath)) { + const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); + if (!ok) return 'Cancelled.'; + } + + const compressed = await this._vfsRead(path); + const content = await this._gunzipString(compressed); + await this._vfsWrite(outPath, content); + await this._vfsDelete(path); + + return `gunzipped: ${path} → ${outPath} (${compressed.length} chars)`; + } + + async _gzipString(str) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + const cs = new CompressionStream('gzip'); + const writer = cs.writable.getWriter(); + writer.write(bytes); + writer.close(); + const compressed = await new Response(cs.readable).arrayBuffer(); + return this._bytesToBase64(new Uint8Array(compressed)); + } + + async _gunzipString(b64) { + const compressed = this._base64ToBytes(b64); + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + writer.write(compressed); + writer.close(); + const decompressed = await new Response(ds.readable).arrayBuffer(); + return new TextDecoder().decode(decompressed); + } + + // ============================================================ + // ENCRYPT / DECRYPT (AES-256-GCM) + // ============================================================ + + async _cmdEncrypt(args) { + const path = args[0]; + if (!path) throw new Error('Usage: encrypt <path>'); + + const exists = await this._vfsExists(path); + if (!exists) throw new Error(`File not found: ${path}`); + + const outPath = path + '.enc'; + if (await this._vfsExists(outPath)) { + const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); + if (!ok) return 'Cancelled.'; + } + + const passphrase = await this._promptPassphrase('Enter passphrase: '); + if (!passphrase || passphrase.length < 4) throw new Error('Passphrase too short (min 4 chars)'); + + const plaintext = await this._vfsRead(path); + const envelope = await this._encryptString(plaintext, passphrase); + await this._vfsWrite(outPath, JSON.stringify(envelope, null, 0)); + await this._vfsDelete(path); + + return `encrypted: ${path} → ${outPath}`; + } + + async _cmdDecrypt(args) { + const path = args[0]; + if (!path || !path.endsWith('.enc')) throw new Error('Usage: decrypt <path.enc>'); + + const exists = await this._vfsExists(path); + if (!exists) throw new Error(`File not found: ${path}`); + + const outPath = path.replace(/\.enc$/, ''); + if (await this._vfsExists(outPath)) { + const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); + if (!ok) return 'Cancelled.'; + } + + const passphrase = await this._promptPassphrase('Enter passphrase: '); + if (!passphrase) throw new Error('Passphrase required'); + + const envelopeJson = await this._vfsRead(path); + let envelope; + try { + envelope = JSON.parse(envelopeJson); + } catch (e) { + throw new Error('Invalid .enc file format (expected JSON envelope)'); + } + + const plaintext = await this._decryptEnvelope(envelope, passphrase); + await this._vfsWrite(outPath, plaintext); + await this._vfsDelete(path); + + return `decrypted: ${path} → ${outPath}`; + } + + async _encryptString(plaintext, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await this._deriveKey(passphrase, salt); + + const encoder = new TextEncoder(); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoder.encode(plaintext) + ); + + return { + v: 1, // format version + alg: 'AES-256-GCM+PBKDF2', + salt: this._bytesToBase64(salt), + iv: this._bytesToBase64(iv), + ct: this._bytesToBase64(new Uint8Array(ciphertext)), + }; + } + + async _decryptEnvelope(envelope, passphrase) { + if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope version'); + + const salt = this._base64ToBytes(envelope.salt); + const iv = this._base64ToBytes(envelope.iv); + const ct = this._base64ToBytes(envelope.ct); + const key = await this._deriveKey(passphrase, salt); + + try { + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ct + ); + return new TextDecoder().decode(decrypted); + } catch (e) { + throw new Error('Decryption failed — wrong passphrase or corrupted file'); + } + } + + async _deriveKey(passphrase, salt) { + const encoder = new TextEncoder(); + const baseKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(passphrase), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + // ============================================================ + // Base64 Helpers (binary-safe, no deprecated btoa/unescape) + // ============================================================ + + _bytesToBase64(bytes) { + // Works in all modern browsers + WebX11 + const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join(''); + return btoa(bin); + } + + _base64ToBytes(b64) { + const bin = atob(b64); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); + } +} + +// ================================================================ +// EXPORTS — uncomment the appropriate line for your loader: +// ================================================================ +// window.CryptoGzipModule = CryptoGzipModule; // global registration +// export { CryptoGzipModule }; // ES module @@ -218,8 +218,8 @@ footer .tagline{color:var(--accent2);margin-top:10px;font-weight:500} </div> <div class="header-meta"> <span class="badge badge-v">v4.2</span> - <span class="badge badge-new">24 core · 12 ext</span> - <span style="color:var(--text2)">110+ commands</span> + <span class="badge badge-new">26 core · 15 ext</span> + <span style="color:var(--text2)">135+ commands</span> </div> </div> </header> @@ -238,6 +238,8 @@ footer .tagline{color:var(--accent2);margin-top:10px;font-weight:500} <a href="#config" class="nav-link">Config</a> <a href="#modules" class="nav-link">Modules</a> <a href="#extras" class="nav-link">Extras</a> + <a href="#busybox" class="nav-link">BusyBox</a> + <a href="#shell" class="nav-link">Shell</a> <a href="#sessions" class="nav-link">Sessions</a> <a href="#storage" class="nav-link">Storage</a> <a href="#security" class="nav-link">Security</a> @@ -259,7 +261,7 @@ footer .tagline{color:var(--accent2);margin-top:10px;font-weight:500} <h2>⛅ Overview</h2> <p>WebX11 v4.2 is a browser-based Unix-like terminal — dynamic modules, AI assistant (Ollama), persistent virtual filesystem, web search, voice input, code editor, and a full skill system. Everything runs client-side in your browser.</p> <div class="feature-grid"> - <div class="feature-card"><h4>🧩 24 Core + 12 Ext</h4><p>Modules loaded at startup. Load new ones from any URL at runtime.</p></div> + <div class="feature-card"><h4>🧩 26 Core + 14 Ext</h4><p>Modules loaded at startup. Shell with pipes, redirects, vars, and scripts.</p></div> <div class="feature-card"><h4>🤖 Agent with Tools</h4><p>15+ tools — file ops, web search, skills, modules. Destructive action confirmations with inline bar + countdown.</p></div> <div class="feature-card"><h4>📚 Skills System</h4><p>Persistent procedural memory. Create, patch, search, export skills for repeatable workflows.</p></div> <div class="feature-card"><h4>💾 VFS + Sessions</h4><p>Virtual filesystem persists in localStorage. Save/load/export sessions.</p></div> @@ -628,7 +630,7 @@ ollamaconnect</pre> <section id="modules"> <h2>🧩 Module System</h2> <div class="callout callout-info"> - <span class="callout-title">24 Core + 12 Ext</span> + <span class="callout-title">26 Core + 14 Ext</span> Modules loaded in dependency order at startup. Toggle ext modules with ⚙ gear on splash. </div> <h3>Commands</h3> @@ -714,6 +716,106 @@ ollamaconnect</pre> <p>Built-in: <code>ll</code> = <code>ls</code>, <code>h</code> = <code>help</code>, <code>cls</code> = <code>clear</code>. Define custom: <code>alias x=ls -la</code>. Remove: <code>unalias x</code>. Persists in localStorage.</p> </section> + <!-- ── BusyBox ── --> + <section id="busybox"> + <h2>🔧 BusyBox Utilities</h2> + <div class="callout callout-success"> + <span class="callout-title">16 Unix Utility Commands</span> + Lightweight utility commands for everyday terminal work — identity, path, text, and system info tools. All work standalone and with shell pipes. + </div> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th><th>Example</th></tr> + <tr><td><code>which</code></td><td>Locate command in module registry + fuzzy suggestions</td><td><code>which ls</code></td></tr> + <tr><td><code>env</code></td><td>List local env vars (webx11_, ollama, user profile)</td><td><code>env</code></td></tr> + <tr><td><code>date</code></td><td>Current time (local, ISO, UTC, Unix timestamp)</td><td><code>date</code></td></tr> + <tr><td><code>cal</code></td><td>ASCII calendar for any month/year</td><td><code>cal 12 2026</code></td></tr> + <tr><td><code>yes</code></td><td>Output repeated string (capped at 50)</td><td><code>yes test</code></td></tr> + <tr><td><code>seq</code></td><td>Generate number sequence</td><td><code>seq 1 5</code></td></tr> + <tr><td><code>base64</code></td><td>Base64 encode/decode</td><td><code>base64 -d aGVsbG8=</code></td></tr> + <tr><td><code>printf</code></td><td>Formatted print (%s, %d, %f)</td><td><code>printf "hello %s" world</code></td></tr> + <tr><td><code>cut</code></td><td>Extract column from file</td><td><code>cut -f2 -d, data.csv</code></td></tr> + <tr><td><code>uniq</code></td><td>Filter adjacent duplicates</td><td><code>uniq lines.txt</code></td></tr> + <tr><td><code>hostname</code></td><td>Show host/platform info</td><td><code>hostname</code></td></tr> + <tr><td><code>whoami</code></td><td>Show current user</td><td><code>whoami</code></td></tr> + <tr><td><code>basename</code></td><td>Strip directory from path</td><td><code>basename /a/b/c.txt</code></td></tr> + <tr><td><code>dirname</code></td><td>Strip filename from path</td><td><code>dirname /a/b/c.txt</code></td></tr> + <tr><td><code>sleep</code></td><td>Countdown delay (1-30s)</td><td><code>sleep 3</code></td></tr> + <tr><td><code>expand</code></td><td>Convert tabs to spaces</td><td><code>expand code.txt</code></td></tr> + </table></div> + <p>All file-reading commands (<code>cut</code>, <code>uniq</code>, <code>expand</code>) work with shell pipes: <code>cat data.csv | cut -f2 -d, | uniq</code>.</p> + </section> + + <!-- ── Shell ── --> + <section id="shell"> + <h2>🐚 Shell</h2> + <div class="callout callout-success"> + <span class="callout-title">Pipes · Redirects · Variables · Scripts</span> + The shell module sits on top of every command — pipes, redirections, variable substitution, command chaining, and VFS script execution work automatically without any prefix. + </div> + + <h3>Pipes <code>|</code></h3> + <p>Chain commands by piping stdout to the next command's stdin. Pipe-aware commands (<code>grep</code>, <code>head</code>, <code>tail</code>, <code>sort</code>, <code>wc</code>, <code>cut</code>, <code>uniq</code>, <code>expand</code>) auto-read from pipe when no file argument is given.</p> + <pre><span class="cmt"># Filter file listing</span> +ls | grep .md + +<span class="cmt"># Count lines matching pattern</span> +cat notes.txt | grep error | wc + +<span class="cmt"># Pipeline: filter → sort → head</span> +cat data.csv | grep 2026 | sort | head -5</pre> + + <h3>Redirects <code>></code> <code>>></code> <code><</code></h3> + <pre><span class="cmt"># Write output to file</span> +cal > /tmp/calendar.txt + +<span class="cmt"># Append to file</span> +echo "new line" >> log.txt + +<span class="cmt"># Read file as stdin</span> +grep error < log.txt</pre> + + <h3>Command Chaining</h3> + <pre><span class="cmt"># AND: next runs only if previous succeeds</span> +touch test.txt && echo "created" + +<span class="cmt"># OR: next runs only if previous fails</span> +rm nonexistent || echo "failed" + +<span class="cmt"># Sequence: runs in order regardless</span> +cd /skills; ls</pre> + + <h3>Variables</h3> + <pre><span class="cmt"># Set a variable</span> +export GREET=hello + +<span class="cmt"># Reference in any command</span> +echo $GREET + +<span class="cmt"># List all vars</span> +set + +<span class="cmt"># Remove a var</span> +unset GREET</pre> + + <h3>Scripts</h3> + <p>VFS files with <code>.sh</code> extension can be executed line by line. Variables persist across lines.</p> + <pre><span class="cmt"># Create a script in VFS</span> +echo 'export NAME=WebX11' > greet.sh +echo 'echo "Hello, $NAME!"' >> greet.sh + +<span class="cmt"># Run it</span> +script greet.sh</pre> + + <h3>Shell Commands</h3> + <div class="table-wrap"><table> + <tr><th>Command</th><th>Description</th></tr> + <tr><td><code>export <var>=<val></code></td><td>Set a shell variable</td></tr> + <tr><td><code>unset <var></code></td><td>Remove a shell variable</td></tr> + <tr><td><code>set</code></td><td>List all shell variables</td></tr> + <tr><td><code>script <path.sh></code></td><td>Run a VFS script file</td></tr> + </table></div> + </section> + <!-- ── Sessions ── --> <section id="sessions"> <h2>💬 Sessions</h2> @@ -814,6 +916,17 @@ listmods</pre> <div class="quick-ref-item"><span class="cmd">alias</span><span class="desc">List aliases</span></div> <div class="quick-ref-item"><span class="cmd">preview</span><span class="desc">HTML preview</span></div> <div class="quick-ref-item"><span class="cmd">upload</span><span class="desc">Upload file</span></div> + <div class="quick-ref-item"><span class="cmd">| pipe</span><span class="desc">Chain commands</span></div> + <div class="quick-ref-item"><span class="cmd">> file</span><span class="desc">Redirect output</span></div> + <div class="quick-ref-item"><span class="cmd">which</span><span class="desc">Locate command</span></div> + <div class="quick-ref-item"><span class="cmd">env</span><span class="desc">Show env vars</span></div> + <div class="quick-ref-item"><span class="cmd">script</span><span class="desc">Run .sh file</span></div> + <div class="quick-ref-item"><span class="cmd">date</span><span class="desc">Show time</span></div> + <div class="quick-ref-item"><span class="cmd">cal</span><span class="desc">Show calendar</span></div> + <div class="quick-ref-item"><span class="cmd">base64</span><span class="desc">Encode/decode</span></div> + <div class="quick-ref-item"><span class="cmd">seq</span><span class="desc">Number sequence</span></div> + <div class="quick-ref-item"><span class="cmd">whoami</span><span class="desc">Show user</span></div> + <div class="quick-ref-item"><span class="cmd">basename</span><span class="desc">Strip dir path</span></div> </div> </div> </section> @@ -823,6 +936,19 @@ listmods</pre> <h2>📋 Changelog</h2> <div class="version-item"> + <div class="v-header"><span class="v-num">v4.2.1 — May 2026</span><span class="v-date">2026-05-27</span></div> + <ul> + <li><strong>BusyBox</strong> — 16 new utility commands: <code>which</code>, <code>env</code>, <code>date</code>, <code>cal</code>, <code>yes</code>, <code>seq</code>, <code>base64</code>, <code>printf</code>, <code>cut</code>, <code>uniq</code>, <code>hostname</code>, <code>whoami</code>, <code>basename</code>, <code>dirname</code>, <code>sleep</code>, <code>expand</code></li> + <li><strong>Shell</strong> — Full pipe (<code>|</code>), redirect (<code>></code>, <code>>></code>, <code><</code>), chain (<code>&&</code>, <code>||</code>, <code>;</code>), variable (<code>$VAR</code>), and VFS script execution (<code>script</code>)</li> + <li>Pipe-aware commands (<code>grep</code>, <code>head</code>, <code>tail</code>, <code>sort</code>, <code>wc</code>, <code>cut</code>, <code>uniq</code>, <code>expand</code>) auto-read from pipe via temp file injection</li> + <li><code>export</code>, <code>unset</code>, <code>set</code> — shell variable management</li> + <li>Multiple ASI and VFS API bug fixes (<code>fs.fsWrite</code> → <code>fs.writeFile</code> across 3 modules)</li> + <li><strong>Test Suite</strong> — 32 automated regression tests. Run with <code>load-local modules/test/test-suite.js</code> then <code>test</code>, <code>test auto</code>, or <code>test list</code></li> + <li>Cache v34 — hard refresh required</li> + </ul> + </div> + + <div class="version-item"> <div class="v-header"><span class="v-num">v4.2 — May 2026</span><span class="v-date">2026-05-25</span></div> <ul> <li><strong>Editor</strong> — <code>edit <path></code> opens inline VFS editor modal</li> @@ -804,7 +804,7 @@ </script> <!-- Async dependency-ordered module loader (replaces 26 synchronous script tags) --> - <script src="modules/core/module-loader.js?v=19"></script> + <script src="modules/core/module-loader.js?v=34"></script> </body> </html> diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index f9e1e98..e983e6a 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -538,6 +538,22 @@ RULES: } return null; } + _trackToolFailure(toolName, error) { + try { + const key = 'webx11_tool_failures'; + const raw = localStorage.getItem(key) || '{}'; + const map = JSON.parse(raw); + if (!map[toolName]) map[toolName] = { count: 0, last: '', errors: [] }; + map[toolName].count++; + map[toolName].last = new Date().toISOString(); + map[toolName].errors.push(error); + if (map[toolName].errors.length > 5) map[toolName].errors.shift(); + localStorage.setItem(key, JSON.stringify(map)); + if (map[toolName].count >= 3) { + this.terminal.print(`⚠️ Tool '${toolName}' has failed ${map[toolName].count} times. Consider creating a workaround skill.`, 'warning'); + } + } catch (e) { /* ignore */ } + } async agenticLoop(messages) { let toolTurns = 0; while (toolTurns < this.MAX_TOOL_TURNS) { @@ -579,8 +595,11 @@ RULES: const obs = this._summarizeObservation(toolResult, 150); const err = this._isToolError(toolResult); if (err) { + this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${err} (${t1}s)`); + this._trackToolFailure(fn.name, err); this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning'); } else { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); } messages.push({ role: 'tool', content: toolResult }); @@ -598,8 +617,11 @@ RULES: const dObs = this._summarizeObservation(toolResult, 150); const dErr = this._isToolError(toolResult); if (dErr) { + this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${dErr} (${t1}s)`); + this._trackToolFailure(fn.name, dErr); this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning'); } else { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); } messages.push({ role: 'tool', content: toolResult }); @@ -803,8 +825,9 @@ RULES: return name === 'write_file' || name === 'delete_file' || name === 'run_command' || name === 'make_directory' || name === 'edit_module'; } async runTool(name, argsStr) { + let a = {}; try { - const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'list_files': { const originalPath = a.path || ''; @@ -1058,7 +1081,7 @@ RULES: for (const [name, meta] of Object.entries(index)) { const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0); if (score > 0) { - const content = fs.fsRead(`/skills/${name}.md`) || ''; + const content = fs.readFile(`/skills/${name}.md`) || ''; matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) }); } } @@ -1078,7 +1101,7 @@ RULES: } const fs = this.terminal.fs; fs.mkdirRecursive('/skills'); - fs.fsWrite(`/skills/${name}.md`, content); + fs.writeFile(`/skills/${name}.md`, content); const index = fs.fsReadJSON('/skills/index.json') || {}; index[name] = { name, description, tags, category, created: index[name]?.created || new Date().toISOString(), updated: new Date().toISOString() }; fs.fsWriteJSON('/skills/index.json', index); @@ -1093,7 +1116,7 @@ RULES: const fs = this.terminal.fs; const index = fs.fsReadJSON('/skills/index.json') || {}; if (!index[pname]) return JSON.stringify({ error: `Skill "${pname}" not found` }); - fs.fsWrite(`/skills/${pname}.md`, pcontent); + fs.writeFile(`/skills/${pname}.md`, pcontent); index[pname].updated = new Date().toISOString(); // Re-parse frontmatter for metadata updates const metaMatch = pcontent.match(/^---\n([\s\S]*?)\n---/); @@ -1134,8 +1157,10 @@ RULES: } } async runModuleTool(name, argsStr) { - const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); - switch (name) { + let a = {}; + try { + a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + switch (name) { case 'view_module': { const modName = a.name || ''; const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); @@ -1211,6 +1236,10 @@ RULES: return JSON.stringify({ modules, total: modules.length, hasLocalEdits: hasMods }); } default: return JSON.stringify({ error: `Unknown module tool: ${name}` }); + } + } catch (e) { + this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() }); + return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) }); } } clearContext() { @@ -1358,11 +1387,11 @@ category: productivity - User says "map out", "diagram", "brainstorm", "organize thoughts" - Visualizing project structure or task decomposition`; - fs.fsWrite('/skills/mindmap-user-guide.md', mindmapSkill); + fs.writeFile('/skills/mindmap-user-guide.md', mindmapSkill); const existingIndex = fs.fsReadJSON('/skills/index.json') || {}; existingIndex['mindmap-user-guide'] = { name: 'mindmap-user-guide', description: 'How to create, view, and manage brainstorming mindmaps using the mindmap terminal module', tags: ['mindmap', 'brainstorming', 'organization', 'planning'], category: 'productivity', created: existingIndex['mindmap-user-guide']?.created || new Date().toISOString(), updated: new Date().toISOString() }; fs.fsWriteJSON('/skills/index.json', existingIndex); - fs.fsWrite('/skills/_seeded', '1'); + fs.writeFile('/skills/_seeded', '1'); } _trackPattern(userMessage) { const lower = userMessage.toLowerCase(); @@ -1381,7 +1410,7 @@ category: productivity for (const [tool, errors] of Object.entries(grouped)) { if (errors.length >= 3) { const skillName = 'tool-' + tool + '-workaround'; - const existing = this.terminal.fs.fsRead('/skills/' + skillName + '.md'); + const existing = this.terminal.fs.readFile('/skills/' + skillName + '.md'); if (existing) continue; const steps = errors.map((e, i) => (i + 1) + '. When calling ' + tool + ', avoid: "' + JSON.stringify(e.args) + '" which caused: ' + e.error).join('\n'); const content = '---\nname: ' + skillName + '\ndescription: Workarounds for ' + tool + ' failures\ntags: [' + tool + ', error, workaround]\ncategory: system\n---\n## Steps\n' + steps; @@ -1397,7 +1426,7 @@ category: productivity const now = Date.now(); const issues = []; for (const [name, meta] of Object.entries(index)) { - const content = fs.fsRead('/skills/' + name + '.md'); + const content = fs.readFile('/skills/' + name + '.md'); if (!content || content.length < 50) { issues.push({ name, issue: 'empty or too short' }); continue; diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js index c4bc786..4ba34b0 100644 --- a/modules/core/module-fs.js +++ b/modules/core/module-fs.js @@ -5,7 +5,11 @@ class FileSystemModule { 'ls': 'ls [path] [-la] — List files and directories', 'cat': 'cat <file> — Display file contents', 'put': 'put <file> <content> — Create or overwrite a file', - 'rm': 'rm <file> — Remove a file; use -r/-rf for directories', + 'rm': 'rm <file> — Remove a file (moved to trash); use -r/-rf for directories', + 'trash': 'trash <file> — Move a file to trash', + 'trash-dir': 'trash-dir <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 <dir> [-p] — Create a directory; -p creates parent paths', 'rmdir': 'rmdir <dir> — Remove a directory', 'mv': 'mv <source> <dest> — Move file or directory', @@ -80,13 +84,52 @@ class FileSystemModule { if(!args[0]) return this.terminal.print('Usage: rm <filename>', 'warning'); if(args[0] === '-r' || args[0] === '-rf') { const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r <dirname>', 'warning'); - if (this.terminal.fs.deleteDirRecursive(t)) this.terminal.print('Removed: ' + t, 'success'); + // 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('Removed: ' + args[0], 'success'); + 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 <filename>', '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 <dirname>', '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 <dirname>', 'warning'); if (args[0] === '-p') { diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 850d627..2510fde 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -99,7 +99,10 @@ window.__moduleLoaderReload = reloadWithSelection; - const CACHE = '?v=18'; + const CACHE = '?v=' + (localStorage.getItem('webx11_cache_ver') || '2026-05-27-14'); + if (!localStorage.getItem('webx11_cache_ver')) { + localStorage.setItem('webx11_cache_ver', '2026-05-27-14'); + } async function init() { try { @@ -153,7 +156,7 @@ // Phase 4: Ext modules (filtered) if (typeof webx11._loadExtModules === 'function') { - const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr']; + const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip']; const extSelected = selected ? extKeys.filter(k => isSelected(k + '.js', selected)) : extKeys; diff --git a/modules/core/module-manager.js b/modules/core/module-manager.js index 8e9e911..c508778 100644 --- a/modules/core/module-manager.js +++ b/modules/core/module-manager.js @@ -42,10 +42,12 @@ } createModuleClass(code, url) { try { - const classMatch = code.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/); + // Strip leading comments to avoid ASI: `return\n/**` → `return; /**` + const cleanCode = code.replace(/^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*\s*/, ''); + const classMatch = cleanCode.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/); if (classMatch) { const className = classMatch[1]; - const mod = new Function('return ' + code + '\nreturn ' + className)(); + const mod = new Function('return ' + cleanCode + '\nreturn ' + className)(); return mod; } throw new Error('No valid module class found'); @@ -78,6 +80,24 @@ } return false; } + loadModuleFromCode(code, url) { + try { + this.terminal.print('Loading module from editor...', 'info'); + const ModuleClass = this.createModuleClass(code, url); + if (ModuleClass) { + const instance = new ModuleClass(); + this.registerModule(instance, url); + if (instance.init) { + instance.init(); + } + this.terminal.print(`Module loaded from editor: ${instance.constructor.name}`, 'success'); + return true; + } + } catch (error) { + this.terminal.print(`Failed to load module from code: ${error.message}`, 'error'); + return false; + } + } getModuleList() { const allNames = new Set([...this.moduleRegistry.map(m => m.name), ...Object.keys(this.modules)]); return Array.from(allNames).map(name => ({ diff --git a/modules/core/module-skills.js b/modules/core/module-skills.js index ed6e14f..9d0adec 100644 --- a/modules/core/module-skills.js +++ b/modules/core/module-skills.js @@ -1,6 +1,30 @@ // Extracted from newOS4 index.html — SkillsModule (v4.2 — Self-improving AI) class SkillsModule { - constructor() { this.commands = {}; this.terminal = null; } + constructor() { + this.commands = {}; + this.terminal = null; + this._skillIndexCache = null; + this._skillIndexDirty = false; + this._skillIndexFlushTimer = null; + } + _readSkillIndex() { + if (this._skillIndexCache !== null) return this._skillIndexCache; + const fs = this.terminal.fs; + this._skillIndexCache = fs.fsReadJSON('/skills/index.json') || {}; + return this._skillIndexCache; + } + _markSkillIndexDirty() { + this._skillIndexDirty = true; + if (this._skillIndexFlushTimer) clearTimeout(this._skillIndexFlushTimer); + this._skillIndexFlushTimer = setTimeout(() => this._flushSkillIndex(), 2000); + } + _flushSkillIndex() { + if (!this._skillIndexDirty || this._skillIndexCache === null) return; + const fs = this.terminal.fs; + fs.fsWriteJSON('/skills/index.json', this._skillIndexCache); + this._skillIndexDirty = false; + this._skillIndexFlushTimer = null; + } getHelpInfo() { return { 'skill-list': 'skill-list — List all installed skills', @@ -35,7 +59,7 @@ } listSkills() { this.ensureSkillsDir(); - const index = this.terminal.fs.fsReadJSON('/skills/index.json') || {}; + const index = this._readSkillIndex(); const names = Object.keys(index); if (names.length === 0) { this.terminal.print('No skills found.', 'info'); @@ -54,7 +78,7 @@ this.terminal.print('Usage: skill-view <name>', 'warning'); return; } - const content = this.terminal.fs.fsRead(`/skills/${name}.md`); + const content = this.terminal.fs.readFile(`/skills/${name}.md`); if (!content) { this.terminal.print(`Skill '${name}' not found`, 'error'); return; @@ -73,15 +97,15 @@ const category = args[2] || 'general'; const tags = (args[3] || '').split(',').filter(Boolean); const fs = this.terminal.fs; - if (fs.fsRead(`/skills/${name}.md`)) { + if (fs.readFile(`/skills/${name}.md`)) { this.terminal.print(`Skill '${name}' already exists. Use skill-patch to modify it.`, 'warning'); return; } const content = '---\nname: ' + name + '\ndescription: ' + description + '\ntags: [' + tags.join(', ') + ']\ncategory: ' + category + '\n---\n## Steps\n1. (to be filled)\n'; - fs.fsWrite(`/skills/${name}.md`, content); - const index = fs.fsReadJSON('/skills/index.json') || {}; + fs.writeFile(`/skills/${name}.md`, content); + const index = this._readSkillIndex(); index[name] = { name, description, category, tags }; - fs.fsWriteJSON('/skills/index.json', index); + this._markSkillIndexDirty(); this.terminal.print(`Skill '${name}' created. Edit its content with skill-patch ${name}`, 'success'); } patchSkill(args) { @@ -91,7 +115,7 @@ return; } const fs = this.terminal.fs; - const existing = fs.fsRead(`/skills/${name}.md`); + const existing = fs.readFile(`/skills/${name}.md`); if (!existing) { this.terminal.print(`Skill '${name}' not found. Use skill-create first.`, 'error'); return; @@ -105,7 +129,7 @@ return; } const newContent = args.slice(1).join(' '); - fs.fsWrite(`/skills/${name}.md`, newContent); + fs.writeFile(`/skills/${name}.md`, newContent); this.terminal.print(`Skill '${name}' patched`, 'success'); } deleteSkill(args) { @@ -115,14 +139,14 @@ return; } const fs = this.terminal.fs; - if (!fs.fsRead(`/skills/${name}.md`)) { + if (!fs.readFile(`/skills/${name}.md`)) { this.terminal.print(`Skill '${name}' not found`, 'error'); return; } - fs.fsWrite(`/skills/${name}.md`, ''); - const index = fs.fsReadJSON('/skills/index.json') || {}; + fs.writeFile(`/skills/${name}.md`, ''); + const index = this._readSkillIndex(); delete index[name]; - fs.fsWriteJSON('/skills/index.json', index); + this._markSkillIndexDirty(); this.terminal.print(`Skill '${name}' deleted`, 'success'); } getWebDAVModule() { @@ -141,12 +165,12 @@ } this.ensureSkillsDir(); const fs = this.terminal.fs; - const content = fs.fsRead(`/skills/${name}.md`); + const content = fs.readFile(`/skills/${name}.md`); if (!content) { this.terminal.print(`Skill '${name}' not found in VFS`, 'error'); return; } - const index = fs.fsReadJSON('/skills/index.json') || {}; + const index = this._readSkillIndex(); const meta = index[name]; try { // Ensure remote skills dir @@ -188,7 +212,7 @@ const content = await resp.text(); this.ensureSkillsDir(); const fs = this.terminal.fs; - fs.fsWrite(`/skills/${name}.md`, content); + fs.writeFile(`/skills/${name}.md`, content); // Try to pull the index to get metadata; if fails, create minimal entry let meta = { name, description: 'Imported from WebDAV', category: 'general', tags: [] }; try { @@ -200,9 +224,9 @@ } } } catch (e) { /* use default meta */ } - const index = fs.fsReadJSON('/skills/index.json') || {}; + const index = this._readSkillIndex(); index[name] = meta; - fs.fsWriteJSON('/skills/index.json', index); + this._markSkillIndexDirty(); this.terminal.print(`Skill '${name}' imported from WebDAV (${content.length} bytes)`, 'success'); } catch (e) { this.terminal.print('Import failed: ' + e.message, 'error'); @@ -216,7 +240,7 @@ } this.ensureSkillsDir(); const fs = this.terminal.fs; - const localIndex = fs.fsReadJSON('/skills/index.json') || {}; + const localIndex = this._readSkillIndex(); const localNames = new Set(Object.keys(localIndex)); this.terminal.print('Syncing skills with WebDAV...', 'info'); let imported = 0, exported = 0; @@ -233,7 +257,7 @@ // Export local-only skills to remote for (const name of localNames) { if (!remoteNames.has(name)) { - const content = fs.fsRead(`/skills/${name}.md`); + const content = fs.readFile(`/skills/${name}.md`); if (content) { await wd._ensureDir('skills'); await wd._webdavRequest('PUT', 'skills/' + name + '.md', { @@ -250,14 +274,14 @@ const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md'); if (resp.ok) { const content = await resp.text(); - fs.fsWrite(`/skills/${name}.md`, content); + fs.writeFile(`/skills/${name}.md`, content); localIndex[name] = remoteIndex[name]; imported++; } } } // Push updated local index back to remote - fs.fsWriteJSON('/skills/index.json', localIndex); + this._markSkillIndexDirty(); await wd._ensureDir('skills'); await wd._webdavRequest('PUT', 'skills/index.json', { body: JSON.stringify(localIndex, null, 2), diff --git a/modules/core/module-system.js b/modules/core/module-system.js index f21946a..b561ef2 100644 --- a/modules/core/module-system.js +++ b/modules/core/module-system.js @@ -14,6 +14,9 @@ 'factory-reset': 'factory-reset [--confirm] — Wipe all webx11_ data (VFS, config, AI profile, mindmaps, themes, all localStorage)', 'reset-vfs': 'reset-vfs [--confirm] — Clear only the virtual filesystem (keeps settings, modules, AI profile)', 'logs': 'logs [n] [LEVEL] — Show last n log entries (optionally filtered by level: INFO/WARN/ERROR)', + 'logrotate': 'logrotate — Rotate current.log to a dated archive (auto-cleans old)', + 'health-check': 'health-check — Audit VFS size, modules, logs, trash, skills, tool failures', + 'memory-map-export': 'memory-map-export — Generate memory map markdown from memory/index.json', 'font-size': 'font-size <8-32> — Set terminal font size (persists across sessions)', 'setfont': 'setfont <8-32> — Alias for font-size', 'smart-prompt': 'smart-prompt [on|off] — Toggle context-aware prompt (shows name, skills, sessions, memory, edits)', @@ -92,6 +95,93 @@ this.terminal.print('--- Logs (last ' + tail.length + ') ---', 'info'); tail.forEach(l => this.terminal.print(l, l.includes('[ERROR]') ? 'error' : 'info')); }; + // Auto-rotate logs on startup (silently) + this._autoRotateLogs(); + this.commands['logrotate'] = () => { + const rotated = this._autoRotateLogs(true); + if (rotated) { + this.terminal.print('Log rotated: ' + rotated, 'success'); + } else { + this.terminal.print('Log rotation not needed (<50KB, <500 lines)', 'info'); + } + }; + this.commands['health-check'] = () => this._healthCheck(); + this.commands['memory-map-export'] = () => this._memoryMapExport(); + } + _autoRotateLogs(force = false) { + try { + const content = this.terminal.fs.readFile('/logs/current.log') || ''; + const lines = content.split('\n').filter(l => l); + const size = content.length; + if (!force && size < 51200 && lines.length < 500) return null; + const now = new Date(); + const stamp = now.getFullYear() + '-' + + String(now.getMonth()+1).padStart(2,'0') + '-' + + String(now.getDate()).padStart(2,'0') + '_' + + String(now.getHours()).padStart(2,'0') + '00'; + this.terminal.fs.writeFile('/logs/' + stamp + '.log', content); + this.terminal.fs.writeFile('/logs/current.log', ''); + // Clean oldest rotated logs (>10) + const logsDir = this.terminal.fs.readDir('/logs'); + if (logsDir && logsDir.content) { + const rotated = Object.keys(logsDir.content) + .filter(k => k.endsWith('.log') && k !== 'current.log') + .sort(); + while (rotated.length > 10) { + const oldest = rotated.shift(); + this.terminal.fs.deleteFile('/logs/' + oldest); + } + } + return stamp + '.log'; + } catch (e) { return null; } + } + _healthCheck() { + const fs = this.terminal.fs; + let warnings = 0; + this.terminal.print('╔══════════════════════════════════════╗', 'info'); + this.terminal.print('║ 🔍 SYSTEM HEALTH CHECK ║', 'info'); + this.terminal.print('╚══════════════════════════════════════╝', 'info'); + // VFS size + const dump = JSON.stringify(fs.root).length; + const vfsOk = dump < 524288; + this.terminal.print(`VFS size: ${(dump/1024).toFixed(1)}KB ${vfsOk ? '✅' : '⚠️ > 512KB'}`, vfsOk ? 'info' : 'warning'); + if (!vfsOk) warnings++; + // Modules + const modTotal = Object.keys(this.terminal.moduleManager.modules || {}).length; + this.terminal.print(`Modules loaded: ${modTotal} ✅`, 'info'); + // Logs + const logContent = fs.readFile('/logs/current.log') || ''; + const logLines = logContent.split('\n').filter(l => l).length; + this.terminal.print(`Current log: ${logLines} lines`, logLines > 500 ? 'warning' : 'info'); + if (logLines > 500) warnings++; + // Trash + const trashList = fs.listTrash ? fs.listTrash() : []; + this.terminal.print(`Trash items: ${trashList.length}`, 'info'); + // Skills + const skillIdx = fs.fsReadJSON ? (fs.fsReadJSON('/skills/index.json') || {}) : {}; + const skillCount = Object.keys(skillIdx).length; + this.terminal.print(`Skills: ${skillCount}`, skillCount > 0 ? 'info' : 'warning'); + if (skillCount === 0) warnings++; + // Summary + this.terminal.print('─'.repeat(40), 'text-muted'); + if (warnings === 0) { + this.terminal.print('All checks passed ✅', 'success'); + } else { + this.terminal.print(`${warnings} warning(s) found`, 'warning'); + } + } + _memoryMapExport() { + const fs = this.terminal.fs; + const idx = fs.fsReadJSON ? (fs.fsReadJSON('/memory/index.json') || {}) : null; + if (!idx) { + this.terminal.print('No memory index found at /memory/index.json', 'error'); + return; + } + this.terminal.print('---\nname: adaptive-memory-map\ndescription: Auto-generated from memory/index.json\ntags: [memory, context, tracking, adaptation]\ncategory: system\n---\n', 'info'); + for (const [k,v] of Object.entries(idx)) { + this.terminal.print(`## ${k}\n- Tasks: ${v.tasks || 0}\n- Updated: ${v.updated || 'unknown'}\n- Summary: ${(v.summary || '').substring(0,100)}...`, 'info'); + } + this.terminal.print('(Not saved — render to file with memory-map-export > /memory/reference.md if desired)', 'text-muted'); } handleHelp(args) { const helpMap = this.terminal.moduleManager.getAllHelp(); @@ -116,8 +206,9 @@ files: ['ls','ll','cat','put','rm','mv','cp','mkdir','rmdir','cd','pwd','find','grep','head','tail','sort','wc','touch','tree','du','upload','download','vfs-export','vfs-import','edit','diff'], ai: ['ai','ask','ai-clear','ai-system','ai-context','ai-allow','ai-deny','ai-stop','ai-model'], search: ['search','s','ddg','duck','fetch','web','extract'], - tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias'], - system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs'], + tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias', + 'which','env','date','cal','yes','seq','base64','printf','cut','uniq','hostname','whoami','basename','dirname','sleep','expand'], + system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs','export','unset','script','set','test'], net: ['server','servers','server-check','server-remove','monitor'], mods: ['modules','listmods','loadmod','removemod','load-local','loadlocal','startup-modules'], ollama: ['ollama','ollamaconnect','ollamadisconnect','ollamamodels','ollamasetmodel','ollamasetproxy','models','model-info','model-pull','model-delete','model-run'], diff --git a/modules/core/terminal.js b/modules/core/terminal.js index ee1afa4..2617f79 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -1,4 +1,184 @@ // Extracted from newOS4 index.html — WebX11Terminal + class WebX11SecurityManager { + constructor(terminal, level = 1) { + this.terminal = terminal; + this.level = level; + this.commandMeta = new Map(); + this.lastRouteTime = 0; + this.lastRouteCmd = ''; + this.blockedCount = 0; + this.destructiveCmds = new Set(['rm','delete','del','edit','write','save','overwrite','format','kill','stop','shutdown','reboot']); + this.systemCmds = new Set(['reboot','shutdown','format','kill','halt','poweroff']); + this.suspiciousFlags = new Set(['--dump','--execute','--eval','--exec','--shell','--script']); + this.whitelist = new Set(['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history']); + this._registerBuiltIn(); + } + _registerBuiltIn() { + const internals = ['clear','help','ls','cat','df','free','env','history','cd','pwd','touch','mkdir','rm','cp','mv','edit','cat','run','font-size','load-local','loadlocal','mem','security','security-level','startup-modules','smart-prompt','cmd-allow','cmd-deny']; + internals.forEach(c => this.registerCommand(c, { category: 'builtin' })); + } + setLevel(n) { + if (n >= 0 && n <= 3) { + this.level = n; + this.terminal.print(`Security level set to ${n} (${this.getLevelName()})`, 'success'); + if (n === 0) { + this.terminal.print('Warning: Permissive mode — commands will not be blocked or confirmed.', 'warning'); + } + } + else { this.terminal.print('Level must be 0-3', 'error'); } + } + getLevel() { return this.level; } + getLevelName() { + return ['Permissive','Standard','Paranoid','Audit'][this.level] || 'Unknown'; + } + registerCommand(name, meta = {}) { + this.commandMeta.set(name, { destructive: false, category: 'user', ...meta }); + } + _collectAllCommands() { + const cmds = new Set([...this.commandMeta.keys()]); + for (const modName in this.terminal.moduleManager.modules) { + const mod = this.terminal.moduleManager.modules[modName]; + if (mod.commands) { + for (const c in mod.commands) cmds.add(c); + } + } + const builtins = ['clear','help','ls','df','security','security-level']; + builtins.forEach(c => cmds.add(c)); + return [...cmds]; + } + _levenshtein(a, b) { + a = a.toLowerCase(); b = b.toLowerCase(); + const m = a.length, n = b.length; + if (!m) return n; if (!n) return m; + let prev = new Uint16Array(n + 1); + let curr = new Uint16Array(n + 1); + for (let j = 0; j <= n; j++) prev[j] = j; + for (let i = 1; i <= m; i++) { + let next = new Uint16Array(n + 1); + next[0] = i; + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + next[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, next]; + } + return prev[n]; + } + suggest(input, maxDist = 2) { + const all = this._collectAllCommands(); + const lower = input.toLowerCase(); + let best = '', bestDist = Infinity; + for (const c of all) { + const d = this._levenshtein(lower, c); + if (d < bestDist && d <= maxDist) { bestDist = d; best = c; } + } + return best ? { cmd: best, dist: bestDist } : null; + } + _classify(raw) { + const parts = raw.trim().split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // Internal commands always allowed at terminal level, but security still classifies + const all = this._collectAllCommands(); + const known = all.includes(cmd); + const hasSuspicious = args.some(a => this.suspiciousFlags.has(a.toLowerCase())); + const destructive = this.destructiveCmds.has(cmd) || hasSuspicious; + const system = this.systemCmds.has(cmd); + const unknown = !known; + return { cmd, args, known, destructive, system, unknown, hasSuspicious }; + } + async checkAndRun(rawInput) { + const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput); + // Level 3 Audit: just log and allow + if (this.level === 3) { + this.terminal.print(`[AUDIT] ${rawInput} → known=${known} destructive=${destructive} system=${system}`, 'info'); + return this._dispatch(cmd, args); + } + // Always allow builtins that are safe, regardless of level + if (['help','security','security-level'].includes(cmd)) { + return this._dispatch(cmd, args); + } + // Unknown command handling + if (unknown && !this.whitelist.has(cmd)) { + this.blockedCount++; + const sug = this.suggest(cmd); + if (sug) { + this.terminal.print(`Security: Unknown command "${cmd}". Did you mean: "${sug.cmd}"?`, 'warning'); + } else { + this.terminal.print(`Security: Unknown command "${cmd}" — blocked. Use 'security-level 0' to disable.`, 'warning'); + } + // At levels 0-1: suggest/fix but still route to AI (terminal is also a chat interface) + // At level 2+: block entirely (paranoid) + if (this.level >= 2) { + this.terminal.print(`Security: Unknown command "${cmd}" blocked (level ${this.level}).`, 'error'); + return; + } + if (this.level >= 1) { + if (sug) { + this.terminal.print(`Security: Did you mean "${sug.cmd}"? Routing to AI...`, 'warning'); + } else { + this.terminal.print(`Security: Routing to AI...`, 'info'); + } + } + return this._tryAIRoute(rawInput); + } + // Suspicious flags (e.g. --execute) at any level > 0 + if (hasSuspicious && this.level > 0) { + this.terminal.print(`Security: Suspicious flag detected in "${rawInput}" — blocked.`, 'error'); + this.blockedCount++; + return; + } + // Destructive commands + if (destructive) { + if (this.level === 0) { + this.terminal.print(`Warning: destructive command "${cmd}"`, 'warning'); + return this._dispatch(cmd, args); + } + if (this.level >= 1) { + // Reuse your existing confirm UI if present, otherwise print confirm message + if (this.terminal.showConfirmationBar) { + this.terminal._pendingCommand = () => this._dispatch(cmd, args); + this.terminal.showConfirmationBar(`Run destructive: ${rawInput}`); + return; + } else { + this.terminal.print(`Security: Destructive command "${cmd}" requires confirmation.`, 'warning'); + return; + } + } + } + // System commands at level >=1 + if (system && this.level >= 1) { + this.terminal.print(`Security: System command "${cmd}" blocked (level ${this.level}).`, 'error'); + this.blockedCount++; + return; + } + // Standard dispatch for everything else + return this._dispatch(cmd, args); + } + async _dispatch(cmd, args) { + return this.terminal.dispatchCommand(cmd, args); + } + async _tryAIRoute(rawInput) { + // AI routing cooldown guard + const now = Date.now(); + if (this.lastRouteCmd === rawInput && (now - this.lastRouteTime) < 5000) { + this.terminal.print(`Security: Already routed "${rawInput}" to AI recently. Use 'security-level 0' to force.`, 'warning'); + return; + } + this.lastRouteCmd = rawInput; + this.lastRouteTime = now; + try { + const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (aiMod) { + this.terminal.print('→ Routing to AI assistant...', 'info'); + await aiMod.chat([rawInput.trim()]); + } + } catch (e) { + this.terminal.print(`Security: AI error: ${e.message}`, 'error'); + } + } + } + class WebX11Terminal { constructor() { this.outputContainer = document.getElementById('terminal-container'); @@ -15,6 +195,7 @@ } } catch (e) { console.warn('SecretVault init:', e.message); } this.moduleManager = new ModuleManager(this); + this.security = new WebX11SecurityManager(this, parseInt(localStorage.getItem('webx11_security_level')) || 1); this.ollamaManager = new OllamaManager(this); this._pipeInput = null; this._pipeBufferSize = 5000; @@ -267,6 +448,53 @@ this.outputContainer.appendChild(line); this.scrollToBottom(); } + showConfirmationBar(desc) { + const area = document.getElementById('ai-confirm-area'); + if (!area) { + this.print('Confirmation UI unavailable — command blocked.', 'error'); + return; + } + this._pendingCommandDesc = desc; + area.innerHTML = ` + <span class="confirm-desc">⚠️ ${desc}</span> + <button class="confirm-btn" id="cmd-confirm-allow" onclick="(function(){var a=document.getElementById('cmd-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button> + <button class="confirm-btn deny" id="cmd-confirm-deny" onclick="(function(){var d=document.getElementById('cmd-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button> + <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: cmd-allow or cmd-deny</span> + `; + area.classList.add('visible'); + const allowBtn = document.getElementById('cmd-confirm-allow'); + const denyBtn = document.getElementById('cmd-confirm-deny'); + const onAllow = () => { + this._resolveCmdConfirm(true); + }; + const onDeny = () => { + this._resolveCmdConfirm(false); + }; + allowBtn.addEventListener('click', onAllow); + denyBtn.addEventListener('click', onDeny); + allowBtn.__allow = onAllow; + denyBtn.__deny = onDeny; + } + _resolveCmdConfirm(approved) { + const area = document.getElementById('ai-confirm-area'); + if (area) { + area.classList.remove('visible'); + area.innerHTML = ''; + } + if (approved) { + this.print(`Allowed: ${this._pendingCommandDesc || 'command'}`, 'success'); + if (this._pendingCommand) { + const fn = this._pendingCommand; + this._pendingCommand = null; + this._pendingCommandDesc = null; + fn(); + } + } else { + this.print(`Denied: ${this._pendingCommandDesc || 'command'}`, 'warning'); + this._pendingCommand = null; + this._pendingCommandDesc = null; + } + } _loadHistory() { try { const saved = localStorage.getItem('webx11_terminal_output'); @@ -331,7 +559,7 @@ setTimeout(() => { this._historySavePending = false; try { - const slice = this._historyBuffer.slice(-30); + const slice = this._historyBuffer.slice(-this._historyMaxLines); localStorage.setItem('webx11_terminal_output', JSON.stringify(slice)); } catch (e) { // Silently ignore storage errors (e.g. quota exceeded) @@ -425,6 +653,55 @@ scrollToBottom() { this.outputContainer.scrollTop = this.outputContainer.scrollHeight; } + _getPathSuggestions(partial, type) { + // type: 'dir' = dirs only, 'file' = files + dirs (dirs get trailing /), 'session' = _sessions/*.json + if (type === 'session') { + this.fs.mkdirRecursive('_sessions'); + const all = this.fs.ls('_sessions'); + const names = all + .filter(e => e.type === 'file' && e.name.endsWith('.json')) + .map(e => e.name.replace('.json', '')); + return partial + ? names.filter(n => n.startsWith(partial)).map(n => n) + : names; + } + // Resolve the partial path to determine which directory to list + let dirPath = ''; + let filterPrefix = ''; + let entries = []; + let prefix = ''; + + if (partial.includes('/')) { + // "dir1/dir2" or "dir1/dir2/" or "dir1/" + const lastSlash = partial.lastIndexOf('/'); + const beforeSlash = partial.slice(0, lastSlash); // dir1 + filterPrefix = partial.slice(lastSlash + 1); // dir2 (or '' if ends with /) + // Resolve "dir1" relative to current path + const resolvedDir = this.fs._resolveDir(beforeSlash || '.'); + if (!resolvedDir) return []; + entries = Object.keys(resolvedDir.content).map(name => ({ + name, + type: resolvedDir.content[name].type, + size: resolvedDir.content[name].content ? resolvedDir.content[name].content.length : 0 + })); + prefix = beforeSlash + '/'; + } else { + // No slash — list current dir + entries = this.fs.ls(); + filterPrefix = partial; + prefix = ''; + } + + if (type === 'dir') { + return entries + .filter(e => e.type === 'dir' && e.name.startsWith(filterPrefix)) + .map(e => prefix + e.name + '/'); + } + // 'file' — show files + dirs (dirs with trailing /) + return entries + .filter(e => e.name.startsWith(filterPrefix)) + .map(e => e.type === 'dir' ? prefix + e.name + '/' : prefix + e.name); + } showAutocomplete() { const input = this.inputField.value; const trimmed = input.trim(); @@ -442,11 +719,24 @@ const cmdPart = parts[0].toLowerCase(); const lowerInput = input.toLowerCase(); - // Commands that take file/dir arguments for autocomplete - const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view']; - const dirArgCommands = ['ls', 'cd']; - // Session commands that autocomplete from _sessions/*.json - const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save']; + // Expanded command lists for autocomplete + const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list']; + const dirArgCommands = ['ls', 'cd', 'mkdir', 'rmdir']; + const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save', 'session-list', 'session-import']; + const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview']; + + // Collect commands that take arguments from ext module metadata + const extFileCmds = new Set(); + const extDirCmds = new Set(); + for (const modName in this.moduleManager.modules) { + const mod = this.moduleManager.modules[modName]; + if (mod.commandsMeta) { + for (const [c, meta] of Object.entries(mod.commandsMeta)) { + if (meta.argType === 'file' || meta.argType === 'path') extFileCmds.add(c); + if (meta.argType === 'dir') extDirCmds.add(c); + } + } + } // If we have more than one word, we're past the command name — autocomplete file/dir/session names if (parts.length > 1 && parts[1].length > 0) { @@ -458,25 +748,13 @@ return; } - // Determine if we should show session names, dirs only, or both if (sessionArgCommands.includes(cmdPart)) { - this.fs.mkdirRecursive('_sessions'); - const all = this.fs.ls('_sessions'); - this.suggestions = all - .filter(e => e.type === 'file' && e.name.endsWith('.json')) - .map(e => e.name.replace('.json', '')) - .filter(n => n.startsWith(partialTrimmed)); - } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { - // Dir-only commands - const entries = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = entries - .filter(e => e.name.startsWith(partialTrimmed)) - .map(e => e.name + '/'); - } else if (fileArgCommands.includes(cmdPart)) { - const all = this.fs.ls(); - this.suggestions = all - .filter(e => e.name.startsWith(partialTrimmed)) - .map(e => e.type === 'dir' ? e.name + '/' : e.name); + this.suggestions = this._getPathSuggestions(partialTrimmed, 'session'); + } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions(partialTrimmed, 'dir'); + this._isCdAutocomplete = true; + } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions(partialTrimmed, 'file'); } else { this.hideAutocomplete(); return; @@ -484,7 +762,6 @@ this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { - this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); this.renderAutocomplete(); } else { this.hideAutocomplete(); @@ -495,24 +772,18 @@ // Just typed a space after command — show all matching entries if (parts.length > 1) { if (sessionArgCommands.includes(cmdPart)) { - this.fs.mkdirRecursive('_sessions'); - const all = this.fs.ls('_sessions'); - this.suggestions = all - .filter(e => e.type === 'file' && e.name.endsWith('.json')) - .map(e => e.name.replace('.json', '')); - } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { - const dirs = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = dirs.map(d => d.name + '/'); - } else if (fileArgCommands.includes(cmdPart)) { - const all = this.fs.ls(); - this.suggestions = all.map(e => e.type === 'dir' ? e.name + '/' : e.name); + this.suggestions = this._getPathSuggestions('', 'session'); + } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions('', 'dir'); + this._isCdAutocomplete = true; + } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions('', 'file'); } else { this.hideAutocomplete(); return; } this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { - this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); this.renderAutocomplete(); } else { this.hideAutocomplete(); @@ -520,10 +791,9 @@ return; } - // Directory autocomplete for bare 'cd ' (no partial yet) — should be unreachable due to above block + // Directory autocomplete for bare 'cd ' (no partial yet) if (lowerInput.startsWith('cd ')) { - const dirs = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = dirs.map(d => d.name + '/'); + this.suggestions = this._getPathSuggestions('', 'dir'); if (this.suggestions.length > 0) { this._isCdAutocomplete = true; this.renderAutocomplete(); @@ -623,15 +893,17 @@ catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); } } async _loadExtModules(filterKeys) { - const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell']; + const cacheVer = localStorage.getItem('webx11_cache_ver') || '2026-05-27-14'; try { - const res = await fetch('packages.json'); + const res = await fetch('packages.json?v=' + cacheVer); if (!res.ok) throw new Error(`HTTP ${res.status}`); const index = await res.json(); for (const key of autoLoadKeys) { const pkg = index.packages[key]; if (pkg) { - await this.moduleManager.loadModuleFromURL(pkg.url); + const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer; + await this.moduleManager.loadModuleFromURL(url); } } } catch (err) { @@ -640,7 +912,7 @@ } async reloadPersistedModules() { const registry = this.moduleManager.moduleRegistry; - const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell']; for (const mod of registry) { if (mod.enabled && mod.url) { // Skip ext modules — they load via _loadExtModules @@ -659,65 +931,8 @@ await this.executePipeChain(pipeParts); return; } - const parts = rawInput.trim().split(/\s+/); - const cmd = parts[0].toLowerCase(); - const args = parts.slice(1); - if (cmd === 'startup-modules') { - this.openStartupModules(); - return; - } - if (cmd === 'font-size' || cmd === 'setfont') { - this.setFontSize(args[0]); - return; - } - if (cmd === 'load-local' || cmd === 'loadlocal') { - this.loadLocalModule(args[0]); - return; - } - if (cmd === 'smart-prompt') { - const val = args[0] && args[0] === 'off' ? 'off' : 'on'; - localStorage.setItem('webx11_smart_prompt', val); - this.print('Smart prompt ' + val, 'success'); - this.updatePrompt(); - return; - } - if (cmd === 'mem') { - this.printMemoryStats(); - return; - } - if (cmd === 'clear') { - this.outputContainer.innerHTML = ''; - this._historyBuffer = []; - localStorage.removeItem('webx11_terminal_output'); - return; - } - let executed = false; - for (const modName in this.moduleManager.modules) { - const mod = this.moduleManager.modules[modName]; - if (mod.commands && mod.commands[cmd]) { - try { - await mod.commands[cmd](args); - executed = true; - } catch (err) { - this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); - } - break; - } - } - if (!executed) { - // Route unknown commands to the AI agent - try { - const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; - if (aiMod) { - this.print('→', 'info'); - await aiMod.chat([rawInput.trim()]); - return; - } - } catch (e) { - this.print(`Agent error: ${e.message}`, 'error'); - return; - } - } + // Security gate for all single commands and internal builtins + await this.security.checkAndRun(rawInput); } splitPipe(input) { const parts = []; @@ -761,6 +976,25 @@ } async dispatchCommand(cmd, args) { if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; } + if (cmd === 'startup-modules') { this.openStartupModules(); return; } + if (cmd === 'font-size' || cmd === 'setfont') { this.setFontSize(args[0]); return; } + if (cmd === 'load-local' || cmd === 'loadlocal') { this.loadLocalModule(args[0]); return; } + if (cmd === 'smart-prompt') { const val = args[0] && args[0] === 'off' ? 'off' : 'on'; localStorage.setItem('webx11_smart_prompt', val); this.print('Smart prompt ' + val, 'success'); this.updatePrompt(); return; } + if (cmd === 'mem') { this.printMemoryStats(); return; } + if (cmd === 'security') { + this.print(`Security level: ${this.security.getLevel()} (${this.security.getLevelName()})`, 'info'); + this.print(`Blocked commands: ${this.security.blockedCount}`, 'info'); + return; + } + if (cmd === 'security-level') { + const lv = parseInt(args[0]); + if (isNaN(lv)) { this.print('Usage: security-level 0|1|2|3', 'warning'); return; } + this.security.setLevel(lv); + localStorage.setItem('webx11_security_level', lv); + return; + } + if (cmd === 'cmd-allow') { this._resolveCmdConfirm(true); return; } + if (cmd === 'cmd-deny') { this._resolveCmdConfirm(false); return; } for (const modName in this.moduleManager.modules) { const mod = this.moduleManager.modules[modName]; if (mod.commands && mod.commands[cmd]) { @@ -768,17 +1002,7 @@ catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; } } } - // Route unknown commands to the AI agent - try { - const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; - if (aiMod) { - this.print('→ Routing to AI assistant...', 'info'); - await aiMod.chat([cmd + ' ' + args.join(' ')]); - return; - } - } catch (e) { - this.print(`Agent error: ${e.message}`, 'error'); - } + // No AI routing here — security manager handles that via checkAndRun this.print(`Command not found: ${cmd}`, 'error'); } openFileBrowser() { diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js index 68d02bf..d2d3103 100644 --- a/modules/core/virtual-fs.js +++ b/modules/core/virtual-fs.js @@ -2,33 +2,83 @@ class VirtualFileSystem { constructor() { this.root = { type: 'dir', content: {} }; this.currentPath = []; - this.loadFromStorage(); - } - loadFromStorage() { + this._ready = this.loadFromStorage(); + } + _u8ToBase64(u8) { + let s = ''; + for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); + return btoa(s); + } + _base64ToU8(b64) { + const s = atob(b64); + const u8 = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); + return u8; + } + async _compress(text) { + if (typeof CompressionStream === 'undefined') return null; + const blob = new Blob([text]); + const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); + const resp = new Response(stream); + const buf = await resp.arrayBuffer(); + return this._u8ToBase64(new Uint8Array(buf)); + } + async _decompress(b64) { + if (typeof DecompressionStream === 'undefined') throw new Error('DecompressionStream not available'); + const u8 = this._base64ToU8(b64); + const blob = new Blob([u8]); + const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); + const resp = new Response(stream); + return await resp.text(); + } + async loadFromStorage() { try { - const saved = localStorage.getItem('webx11_filesystem'); - if (saved) { - // Safety: refuse to load if data is excessively large (>512KB raw) - if (saved.length > 524288) { - console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load'); - localStorage.removeItem('webx11_filesystem'); - return; + const v2 = localStorage.getItem('webx11_filesystem_v2'); + if (v2) { + try { + const text = await this._decompress(v2); + const data = JSON.parse(text); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + return true; + } catch (e) { + console.error('FS v2 decompress error:', e); + return this._loadLegacy(); } - const data = JSON.parse(saved); - this.root = data.root || this.root; - this.currentPath = data.currentPath || []; } - } catch (e) { console.error('FS load error:', e); } + return this._loadLegacy(); + } catch (e) { console.error('FS load error:', e); return false; } + } + _loadLegacy() { + const saved = localStorage.getItem('webx11_filesystem'); + if (saved) { + if (saved.length > 524288) { + console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load'); + localStorage.removeItem('webx11_filesystem'); + return false; + } + const data = JSON.parse(saved); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + console.info('Loaded legacy FS; will migrate to compressed on next save.'); + return true; + } + return false; } saveToStorage() { if (this._storageSavePending) return; this._storageSavePending = true; - setTimeout(() => { + setTimeout(async () => { this._storageSavePending = false; try { - localStorage.setItem('webx11_filesystem', JSON.stringify({ - root: this.root, currentPath: this.currentPath - })); + const json = JSON.stringify({ root: this.root, currentPath: this.currentPath }); + const compressed = await this._compress(json); + if (compressed) { + localStorage.setItem('webx11_filesystem_v2', compressed); + localStorage.removeItem('webx11_filesystem'); + } else { + localStorage.setItem('webx11_filesystem', json); + } } catch (e) { console.error('FS save error:', e); } }, 2000); } @@ -122,25 +172,62 @@ class VirtualFileSystem { if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'file') { - delete resolved.dir.content[resolved.name]; - this.saveToStorage(); + this.trashFile(path, node); return true; } return false; } - mkdir(path) { + trashFile(path, node) { + try { + const name = path.split('/').pop() || 'unknown'; + const ts = Date.now(); + if (!this.root.content['.trash']) { + this.root.content['.trash'] = { type: 'dir', content: {} }; + } + const trash = this.root.content['.trash']; + const trashName = ts + '-' + name; + trash.content[trashName] = JSON.parse(JSON.stringify(node)); + const resolved = this._resolvePath(path); + if (resolved) delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + } catch (e) { console.error('Trash error:', e); } + } + trashDir(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; - const name = resolved.name; - if (name === '.' || name === '..') return false; - if (resolved.dir.content[name]) return false; - resolved.dir.content[name] = { type: 'dir', content: {} }; - this.saveToStorage(); - return true; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') { + try { + const name = resolved.name; + const ts = Date.now(); + if (!this.root.content['.trash']) { + this.root.content['.trash'] = { type: 'dir', content: {} }; + } + const trash = this.root.content['.trash']; + const flatten = (n, prefix) => { + if (n.type === 'file') { + const flatName = prefix + '-' + name; + let ctr = 0; + let unique = flatName; + while (trash.content[unique]) { unique = flatName + '-' + (++ctr); } + trash.content[unique] = JSON.parse(JSON.stringify(n)); + } else if (n.type === 'dir') { + for (const [childName, child] of Object.entries(n.content || {})) { + flatten(child, prefix + '-' + childName); + } + } + }; + flatten(node, ts + '-dir-' + name); + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } catch (e) { console.error('Trash dir error:', e); } + } + return false; } mkdirRecursive(path) { const segs = this._toAbsoluteSegments(path); - if (!segs || segs.length === 0) return true; + if (!segs) return false; let dir = this.root; for (const seg of segs) { if (!dir.content[seg]) { @@ -153,122 +240,209 @@ class VirtualFileSystem { this.saveToStorage(); return true; } - readDir(path) { - const resolved = this._resolvePath(path); - if (!resolved) return null; - if (resolved.name) { - const node = resolved.dir.content[resolved.name]; - return (node && node.type === 'dir') ? node : null; - } - return resolved.dir; - } - copyFile(srcPath, dstPath) { - const src = this._resolvePath(srcPath); + rename(oldPath, newPath) { + const src = this._resolvePath(oldPath); if (!src || !src.name) return false; - const srcNode = src.dir.content[src.name]; - if (!srcNode || srcNode.type !== 'file') return false; - const dst = this._resolvePath(dstPath); + const dst = this._resolvePath(newPath); if (!dst || !dst.name) return false; - const copy = JSON.parse(JSON.stringify(srcNode)); - dst.dir.content[dst.name] = copy; + const node = src.dir.content[src.name]; + if (!node) return false; + dst.dir.content[dst.name] = node; + delete src.dir.content[src.name]; this.saveToStorage(); return true; } - copyDir(srcPath, dstPath) { + copy(srcPath, dstPath) { const src = this._resolvePath(srcPath); if (!src || !src.name) return false; - const srcNode = src.dir.content[src.name]; - if (!srcNode || srcNode.type !== 'dir') return false; const dst = this._resolvePath(dstPath); if (!dst || !dst.name) return false; - if (dst.dir.content[dst.name]) return false; - const copy = JSON.parse(JSON.stringify(srcNode)); - dst.dir.content[dst.name] = copy; + const node = src.dir.content[src.name]; + if (!node) return false; + dst.dir.content[dst.name] = JSON.parse(JSON.stringify(node)); this.saveToStorage(); return true; } - moveFile(srcPath, dstPath) { - if (this.copyFile(srcPath, dstPath)) { - const src = this._resolvePath(srcPath); - if (src && src.name) { - delete src.dir.content[src.name]; - this.saveToStorage(); - return true; - } + move(srcPath, dstPath) { + return this.rename(srcPath, dstPath); + } + cp(srcPath, dstPath) { + return this.copy(srcPath, dstPath); + } + mv(srcPath, dstPath) { + return this.rename(srcPath, dstPath); + } + rm(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (!node) return false; + if (node.type === 'file') { + this.trashFile(path, node); + return true; + } + if (node.type === 'dir') { + this.trashDir(path); + return true; } return false; } - moveDir(srcPath, dstPath) { - if (this.copyDir(srcPath, dstPath)) { - const src = this._resolvePath(srcPath); - if (src && src.name) { - delete src.dir.content[src.name]; - this.saveToStorage(); - return true; - } + rmdir(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') { + if (Object.keys(node.content).length > 0) return false; + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; } return false; } - // Old-compat aliases for agent tools and SkillsModule - fsRead(path) { - const content = this.readFile(path); - return content !== null ? content : null; + touch(path) { + return this.writeFile(path, ''); } - fsWrite(path, content) { - return this.writeFile(path, content); + readDir(path) { + const resolved = this._resolvePath(path); + if (!resolved) return null; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return null; + } else { + dir = resolved.dir; + } + return Object.keys(dir.content).map(name => ({ + name, + type: dir.content[name].type, + size: dir.content[name].content ? dir.content[name].content.length : 0 + })); + } + fsWriteJSON(path, obj) { + return this.writeFile(path, JSON.stringify(obj)); } fsReadJSON(path) { - const c = this.fsRead(path); - if (c === null) return null; - try { return JSON.parse(c); } catch (e) { return null; } + const content = this.readFile(path); + if (!content) return null; + try { + return JSON.parse(content); + } catch (e) { + return null; + } } - fsWriteJSON(path, obj) { - return this.fsWrite(path, JSON.stringify(obj)); + tree(path = '', prefix = '') { + const resolved = this._resolvePath(path); + if (!resolved) return ''; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return ''; + } else { + dir = resolved.dir; + } + let result = ''; + const entries = Object.keys(dir.content).sort(); + for (let i = 0; i < entries.length; i++) { + const name = entries[i]; + const isLast = i === entries.length - 1; + const connector = isLast ? '└── ' : '├── '; + result += prefix + connector + name + '\n'; + if (dir.content[name].type === 'dir') { + const ext = isLast ? ' ' : '│ '; + result += this.tree(path + '/' + name, prefix + ext); + } + } + return result; } - findFiles(pattern) { + find(pattern, path = '') { + const resolved = this._resolvePath(path); + if (!resolved) return []; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return []; + } else { + dir = resolved.dir; + } const results = []; - const search = (node, path) => { - if (node.type === 'dir' && node.content) { - for (const k of Object.keys(node.content)) { - const sub = node.content[k]; - const subPath = path ? path + '/' + k : k; - if (sub.type === 'dir') search(sub, subPath); - else if (sub.type === 'file' && k.toLowerCase().includes(pattern)) results.push(subPath); - } + const regex = new RegExp(pattern, 'i'); + for (const name of Object.keys(dir.content)) { + const fullPath = path ? path + '/' + name : name; + if (regex.test(name)) { + results.push({ path: fullPath, type: dir.content[name].type }); } - }; - search(this.root, ''); - return results.sort(); + if (dir.content[name].type === 'dir') { + results.push(...this.find(pattern, fullPath)); + } + } + return results; + } + grep(pattern, path = '') { + const results = []; + const regex = new RegExp(pattern, 'i'); + const files = this.find('.', path).filter(e => e.type === 'file'); + for (const file of files) { + const content = this.readFile(file.path); + if (content && regex.test(content)) { + results.push(file.path); + } + } + return results; } - deleteDirRecursive(path) { + chmod(path, mode) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; - if (!node || node.type !== 'dir') return false; - delete resolved.dir.content[resolved.name]; + if (!node) return false; + node.mode = mode; this.saveToStorage(); return true; } - // Full state serialization for session save/restore - serializeState() { - return JSON.stringify({ - root: this.root, - currentPath: this.currentPath, - version: 1 - }); + du(path = '') { + const resolved = this._resolvePath(path); + if (!resolved) return 0; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return node.content ? node.content.length : 0; + } else { + dir = resolved.dir; + } + let size = 0; + for (const name of Object.keys(dir.content)) { + const node = dir.content[name]; + if (node.type === 'file') { + size += node.content ? node.content.length : 0; + } else if (node.type === 'dir') { + size += this.du(path ? path + '/' + name : name); + } + } + return size; + } + getSize(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return 0; + const node = resolved.dir.content[resolved.name]; + if (!node) return 0; + if (node.type === 'file') return node.content ? node.content.length : 0; + return this.du(path); } - restoreState(json) { - try { - const data = JSON.parse(json); - if (!data || !data.root || !data.root.type) return false; - this.root = data.root; - this.currentPath = data.currentPath || []; - this.saveToStorage(); - return true; - } catch (e) { - console.error('FS restore error:', e); - return false; + exportToJSON(path = '') { + const resolved = this._resolvePath(path); + if (!resolved) return null; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return JSON.stringify(node); + } else { + dir = resolved.dir; } + return JSON.stringify(dir, null, 2); } // Restore VFS from an exported dump file (replaces entire root) restoreFromExport(content) { @@ -276,8 +450,15 @@ class VirtualFileSystem { try { content = JSON.parse(content); } catch (e) { return false; } } if (!content || !content.type) return false; - this.root = content; - this.currentPath = []; + // Handle v2 structured export: { type, version, root, currentPath, ... } + if (content.version === 2 || content.root) { + this.root = content.root; + this.currentPath = content.currentPath || []; + } else { + // Legacy format: content is just the root object + this.root = content; + this.currentPath = []; + } this.saveToStorage(); return true; } @@ -309,7 +490,10 @@ class VirtualFileSystem { } rollback(name) { const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json'); - if (!raw || !raw.root) return false; + if (!raw || !raw.root) { + console.error('Rollback failed: checkpoint not found or invalid'); + return false; + } this.root = raw.root; this.currentPath = raw.currentPath || []; this.saveToStorage(); @@ -317,15 +501,29 @@ class VirtualFileSystem { } listCheckpoints() { const dir = this.readDir('/_snapshots'); - if (!dir || !dir.content) return []; - const prefix = 'checkpoint-'; - const suffix = '.json'; - return Object.keys(dir.content) - .filter(k => k.startsWith(prefix) && k.endsWith(suffix)) - .map(k => k.slice(prefix.length, k.length - suffix.length)) - .sort(); + if (!dir) return []; + return dir + .filter(e => e.type === 'file' && e.name.startsWith('checkpoint-') && e.name.endsWith('.json')) + .map(e => e.name.replace('checkpoint-', '').replace('.json', '')); } deleteCheckpoint(name) { - return this.deleteFile('/_snapshots/checkpoint-' + name + '.json'); + const path = '/_snapshots/checkpoint-' + name + '.json'; + const resolved = this._resolvePath(path); + if (resolved && resolved.name) { + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } + return false; } + // Old-compat aliases for agent tools and SkillsModule + read(path) { return this.readFile(path); } + write(path, content) { return this.writeFile(path, content); } + delete(path) { return this.deleteFile(path); } + mkdir(path) { return this.mkdirRecursive(path); } + list(path) { return this.ls(path); } + dir(path) { return this.ls(path); } } + +// Also expose for module system +window.VirtualFileSystem = VirtualFileSystem; diff --git a/modules/ext/busybox.js b/modules/ext/busybox.js new file mode 100644 index 0000000..f143087 --- /dev/null +++ b/modules/ext/busybox.js @@ -0,0 +1,407 @@ +class BusyBoxModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'which': 'which <name> — Locate a command in the module registry', + 'env': 'env — List environment variables (webx11_ keys + user profile)', + 'date': 'date — Show current date/time (ISO, local, UTC, timestamp)', + 'cal': 'cal [month] [year] — Display an ASCII calendar', + 'yes': 'yes [string] — Output repeated string (default "y", capped at 50)', + 'seq': 'seq <last> or seq <first> <last> — Generate sequence of numbers', + 'base64': 'base64 [-d] <string> — Base64 encode/decode', + 'printf': 'printf <format> [args...] — Formatted print (%s, %d, %f, \\n, \\t)', + 'cut': 'cut -f<N> [-d<delim>] <file> — Extract column from file', + 'uniq': 'uniq <file> — Filter adjacent duplicate lines from a file', + 'hostname': 'hostname — Show host/user-agent info', + 'whoami': 'whoami — Show current user name', + 'basename': 'basename <path> — Strip directory from path', + 'dirname': 'dirname <path> — Strip filename from path', + 'sleep': 'sleep <seconds> — Delay execution with countdown', + 'expand': 'expand <file> — Convert tabs to spaces (default 4) in file', + }; + } + + registerCommands() { + this.commands['which'] = (args) => this._cmdWhich(args); + this.commands['env'] = () => this._cmdEnv(); + this.commands['date'] = (args) => this._cmdDate(args); + this.commands['cal'] = (args) => this._cmdCal(args); + this.commands['yes'] = (args) => this._cmdYes(args); + this.commands['seq'] = (args) => this._cmdSeq(args); + this.commands['base64'] = (args) => this._cmdBase64(args); + this.commands['printf'] = (args) => this._cmdPrintf(args); + this.commands['cut'] = (args) => this._cmdCut(args); + this.commands['uniq'] = (args) => this._cmdUniq(args); + this.commands['hostname'] = () => this._cmdHostname(); + this.commands['whoami'] = () => this._cmdWhoami(); + this.commands['basename'] = (args) => this._cmdBasename(args); + this.commands['dirname'] = (args) => this._cmdDirname(args); + this.commands['sleep'] = (args) => this._cmdSleep(args); + this.commands['expand'] = (args) => this._cmdExpand(args); + } + + // ── which ── + + _cmdWhich(args) { + if (!args[0]) return this.terminal.print('Usage: which <name>', 'warning'); + const query = args[0].toLowerCase(); + const all = this.terminal.moduleManager.getAllCommands(); + const keys = Object.keys(all); + + if (all[query]) { + const mod = this.terminal.moduleManager._findModuleForCommand(query); + const src = mod ? ` (from ${mod.constructor.name})` : ''; + return this.terminal.print(`${query}${src}`, 'info'); + } + + // Fuzzy match: check Levenshtein distance up to 2 + let best = null, bestDist = 3; + for (const k of keys) { + const d = this._levenshtein(query, k); + if (d < bestDist) { best = k; bestDist = d; } + } + if (best) { + this.terminal.print(`which: no '${query}' in registry`, 'error'); + this.terminal.print(`Did you mean '${best}'?`, 'info'); + } else { + this.terminal.print(`which: no '${query}' in registry`, 'error'); + } + } + + _levenshtein(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]); + } + } + return dp[m][n]; + } + + // ── env ── + + _cmdEnv() { + const lines = []; + // webx11_ localStorage keys + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith('webx11_')) { + let v = localStorage.getItem(k) || ''; + if (v.length > 80) v = v.substring(0, 77) + '...'; + lines.push(`${k}=${v}`); + } + } + // Runtime info + if (this.terminal.ollamaManager) { + lines.push(`OLLAMA_MODEL=${this.terminal.ollamaManager.currentModel || 'none'}`); + lines.push(`OLLAMA_CONNECTED=${this.terminal.ollamaManager.connected}`); + } + lines.push(`PWD=/${this.terminal.fs.currentPath.join('/')}`); + lines.push(`SHELL=webx11-terminal`); + lines.push(`USER=darkbot`); + lines.push(`HOME=/`); + lines.push(`TERM=xterm-256color`); + const prof = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (prof && prof._userProfile) { + const facts = prof._userProfile.knownFacts; + if (facts) { + for (const [k, v] of Object.entries(facts)) { + lines.push(`USER_${k.toUpperCase()}=${v}`); + } + } + } + this.terminal.print(lines.join('\n'), 'info'); + } + + // ── date ── + + _cmdDate(args) { + const n = new Date(); + if (args.length > 0) { + const flag = args[0].toLowerCase(); + if (flag === '-u' || flag === '--utc' || flag === 'utc') + return this.terminal.print(n.toUTCString(), 'info'); + if (flag === '-i' || flag === '--iso' || flag === 'iso') + return this.terminal.print(n.toISOString(), 'info'); + if (flag === '-t' || flag === '--ts' || flag === 'timestamp') + return this.terminal.print(String(n.getTime()), 'info'); + if (flag === '-r' || flag === '--rfc' || flag === 'rfc') + return this.terminal.print(n.toUTCString(), 'info'); + } + const local = n.toLocaleString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + timeZoneName: 'short' + }); + this.terminal.print(local, 'info'); + this.terminal.print(`ISO: ${n.toISOString()}`, 'info'); + this.terminal.print(`UTC: ${n.toUTCString()}`, 'info'); + this.terminal.print(`Unix: ${Math.floor(n.getTime() / 1000)}`, 'info'); + } + + // ── cal ── + + _cmdCal(args) { + const now = new Date(); + let year = now.getFullYear(); + let month = now.getMonth() + 1; + + if (args.length >= 2) { + month = parseInt(args[0]); + year = parseInt(args[1]); + } else if (args.length === 1) { + // If only one arg, could be year (4 digits) or month + const v = parseInt(args[0]); + if (args[0].length === 4 || v > 12) { + year = v; + } else { + month = v; + } + } + + if (isNaN(month) || isNaN(year) || month < 1 || month > 12) { + return this.terminal.print('cal: invalid month or year', 'error'); + } + + const monthNames = ['January','February','March','April','May','June', + 'July','August','September','October','November','December']; + + const first = new Date(year, month - 1, 1); + const last = new Date(year, month, 0); + const daysInMonth = last.getDate(); + const startDay = first.getDay(); // 0=Sun + + const header = ` ${monthNames[month-1]} ${year}`; + const weekdays = 'Su Mo Tu We Th Fr Sa'; + + let cal = header + '\n' + weekdays + '\n'; + + let line = ' '.repeat(startDay); + for (let d = 1; d <= daysInMonth; d++) { + line += String(d).padStart(2) + ' '; + if ((startDay + d) % 7 === 0 || d === daysInMonth) { + cal += line.trimEnd() + '\n'; + line = ''; + } + } + + this.terminal.print(cal, 'info'); + } + + // ── yes ── + + _cmdYes(args) { + const text = args.length > 0 ? args.join(' ') : 'y'; + const count = 50; + for (let i = 0; i < count; i++) { + this.terminal.print(text); + } + this.terminal.print(`(repeated ${count} times, capped)`, 'text-muted'); + } + + // ── seq ── + + _cmdSeq(args) { + if (args.length === 0) return this.terminal.print('Usage: seq <last> or seq <first> <last>', 'warning'); + let first = 1, last; + if (args.length === 1) { + last = parseInt(args[0]); + } else { + first = parseInt(args[0]); + last = parseInt(args[1]); + } + if (isNaN(first) || isNaN(last)) return this.terminal.print('seq: non-integer argument', 'error'); + const max = 1000; + if (last - first > max) return this.terminal.print(`seq: range too large (max ${max})`, 'error'); + const output = []; + if (first <= last) { + for (let i = first; i <= last; i++) output.push(String(i)); + } else { + for (let i = first; i >= last; i--) output.push(String(i)); + } + this.terminal.print(output.join('\n'), 'info'); + } + + // ── base64 ── + + _cmdBase64(args) { + if (args.length === 0) return this.terminal.print('Usage: base64 [-d] <string>', 'warning'); + if (args[0] === '-d' || args[0] === '--decode') { + if (args.length < 2) return this.terminal.print('Usage: base64 -d <encoded>', 'warning'); + const encoded = args.slice(1).join(' '); + try { + const decoded = atob(encoded); + this.terminal.print(decoded, 'info'); + } catch (e) { + this.terminal.print('base64: invalid input (not valid base64)', 'error'); + } + } else { + const input = args.join(' '); + this.terminal.print(btoa(input), 'info'); + } + } + + // ── printf ── + + _cmdPrintf(args) { + if (args.length === 0) return this.terminal.print('Usage: printf <format> [args...]', 'warning'); + let fmt = args[0]; + const vals = args.slice(1); + let vi = 0; + // Replace format specifiers step by step + const result = fmt + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\\\/g, '\\') + .replace(/%s/g, () => vals[vi++] || '') + .replace(/%d/g, () => { + const v = parseInt(vals[vi++]); + return isNaN(v) ? '0' : String(v); + }) + .replace(/%f/g, () => { + const v = parseFloat(vals[vi++]); + return isNaN(v) ? '0.000000' : v.toFixed(6); + }) + .replace(/%[^sdf%]/g, (m) => `(unsupported: ${m})`); + this.terminal.print(result, 'info'); + } + + // ── cut ── + + _cmdCut(args) { + if (args.length < 2) return this.terminal.print('Usage: cut -f<N> [-d<delim>] <file>', 'warning'); + let field = null, delim = '\t', file = ''; + for (const a of args) { + if (a.startsWith('-f')) field = parseInt(a.substring(2)); + else if (a.startsWith('-d')) delim = a.substring(2); + else file = a; + } + if (!field || isNaN(field)) return this.terminal.print('cut: -f flag required', 'error'); + if (!file) return this.terminal.print('cut: file argument required', 'error'); + + const content = this.terminal.fs.readFile(file); + if (content === null) return this.terminal.print(`cut: ${file}: No such file`, 'error'); + + const lines = content.split('\n').filter(l => l); + const output = []; + for (const line of lines) { + const parts = line.split(delim); + const idx = field - 1; + if (idx >= 0 && idx < parts.length) { + output.push(parts[idx]); + } + } + this.terminal.print(output.join('\n'), 'info'); + } + + // ── uniq ── + + _cmdUniq(args) { + if (!args[0]) return this.terminal.print('Usage: uniq <file>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if (content === null) return this.terminal.print(`uniq: ${args[0]}: No such file`, 'error'); + const lines = content.split('\n'); + let last = null; + const output = []; + for (const line of lines) { + if (line !== last) { + output.push(line); + last = line; + } + } + this.terminal.print(output.join('\n'), 'info'); + } + + // ── hostname ── + + _cmdHostname() { + const ua = navigator.userAgent; + const space = 'newos4'; + const platform = navigator.platform || 'unknown'; + const version = 'WebX11 v4.2'; + this.terminal.print(`Hostname: ${space}`, 'info'); + this.terminal.print(`Platform: ${version} on ${platform}`, 'info'); + this.terminal.print(`User Agent: ${ua}`, 'info'); + } + + // ── whoami ── + + _cmdWhoami() { + // Try to get from user profile + let name = 'darkbot'; + try { + const prof = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (prof && prof._userProfile && prof._userProfile.knownFacts && prof._userProfile.knownFacts.name) { + name = prof._userProfile.knownFacts.name; + } + } catch (e) {} + this.terminal.print(name, 'info'); + } + + // ── basename ── + + _cmdBasename(args) { + if (!args[0]) return this.terminal.print('Usage: basename <path>', 'warning'); + const parts = args[0].split('/').filter(p => p); + const result = parts.pop() || '/'; + this.terminal.print(result, 'info'); + } + + // ── dirname ── + + _cmdDirname(args) { + if (!args[0]) return this.terminal.print('Usage: dirname <path>', 'warning'); + const parts = args[0].split('/').filter(p => p); + if (parts.length <= 1) { + this.terminal.print('.', 'info'); + return; + } + parts.pop(); + const result = args[0].startsWith('/') ? '/' + parts.join('/') : parts.join('/'); + this.terminal.print(result || '.', 'info'); + } + + // ── sleep ── + + _cmdSleep(args) { + if (!args[0]) return this.terminal.print('Usage: sleep <seconds>', 'warning'); + const secs = parseInt(args[0]); + if (isNaN(secs) || secs < 1) return this.terminal.print('sleep: invalid duration', 'error'); + const max = 30; + const actual = Math.min(secs, max); + if (actual !== secs) this.terminal.print(`sleep: capped to ${max}s (from ${secs}s)`, 'warning'); + this.terminal.print(`Sleeping ${actual}s...`, 'info'); + let remaining = actual; + const interval = setInterval(() => { + remaining--; + if (remaining > 0) { + this.terminal.print(`${remaining}s...`, 'info'); + } else { + clearInterval(interval); + this.terminal.print('Done', 'success'); + } + }, 1000); + } + + // ── expand ── + + _cmdExpand(args) { + if (!args[0]) return this.terminal.print('Usage: expand <file>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if (content === null) return this.terminal.print(`expand: ${args[0]}: No such file`, 'error'); + const expanded = content.replace(/\t/g, ' '); + this.terminal.print(expanded, 'info'); + } +} diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js new file mode 100644 index 0000000..6f873ff --- /dev/null +++ b/modules/ext/cryptogzip.js @@ -0,0 +1,281 @@ +class CryptoGzipModule { + // CryptoGzipModule — VFS-level gzip/encrypt commands for WebX11 newOS4 + // Commands: gzip <path>, gunzip <path.gz>, encrypt <path>, decrypt <path.enc> + constructor(terminal) { + this.terminal = terminal; + this.commands = {}; + } + + _vfsExists(path) { + // readFile returns null if not found + const normalized = path.replace(/^\/+/, ''); + return this.terminal.fs.readFile(normalized) !== null; + } + + _vfsRead(path) { + const normalized = path.replace(/^\/+/, ''); + const content = this.terminal.fs.readFile(normalized); + if (content === null) throw new Error(`File not found: ${path}`); + return content; + } + + _vfsWrite(path, content) { + const normalized = path.replace(/^\/+/, ''); + return this.terminal.fs.writeFile(normalized, content); + } + + _vfsDelete(path) { + const normalized = path.replace(/^\/+/, ''); + this.terminal.fs.deleteFile(normalized); + } + + // ============================================================ + // Prompt Utilities (WebX11 terminal compatible) + // ============================================================ + + _promptPassphrase(message = 'Enter passphrase: ') { + return new Promise((resolve) => { + this.terminal.print(message, 'info'); + const savedHandler = this.terminal.inputCallback || this.terminal._onCommand; + const handler = (line) => { + if (this.terminal.inputCallback === handler) this.terminal.inputCallback = savedHandler; + if (this.terminal._onCommand === handler) this.terminal._onCommand = savedHandler; + resolve(line); + }; + this.terminal.inputCallback = handler; + this.terminal._onCommand = handler; + }); + } + + _confirmPrompt(message) { + return new Promise((resolve) => { + this.terminal.print(message + ' (yes/no): ', 'warning'); + const savedHandler = this.terminal.inputCallback || this.terminal._onCommand; + const handler = (line) => { + if (this.terminal.inputCallback === handler) this.terminal.inputCallback = savedHandler; + if (this.terminal._onCommand === handler) this.terminal._onCommand = savedHandler; + resolve(line.trim().toLowerCase() === 'yes'); + }; + this.terminal.inputCallback = handler; + this.terminal._onCommand = handler; + }); + } + + // ============================================================ + // GZIP + // ============================================================ + + async _cmdGzip(args) { + const path = args[0]; + if (!path) { + this.terminal.print('Usage: gzip <path> — Compress a VFS file to .gz', 'warning'); + return; + } + if (!this._vfsExists(path)) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + + const outPath = path + '.gz'; + if (this._vfsExists(outPath)) { + const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); + if (!ok) { this.terminal.print('Cancelled.', 'info'); return; } + } + + try { + const content = this._vfsRead(path); + const compressed = await this._gzipString(content); + this._vfsWrite(outPath, compressed); + this._vfsDelete(path); + const ratio = (compressed.length / Math.max(content.length, 1)).toFixed(2); + this.terminal.print(`gzipped: ${path} → ${outPath} (${content.length} → ${compressed.length} chars, ratio ${ratio})`, 'success'); + } catch (e) { + this.terminal.print('gzip error: ' + e.message, 'error'); + } + } + + async _cmdGunzip(args) { + const path = args[0]; + if (!path || !path.endsWith('.gz')) { + this.terminal.print('Usage: gunzip <path.gz> — Decompress a .gz file in VFS', 'warning'); + return; + } + if (!this._vfsExists(path)) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + + const outPath = path.replace(/\.gz$/, ''); + if (this._vfsExists(outPath)) { + const ok = await this._confirmPrompt(`Overwrite ${outPath}?`); + if (!ok) { this.terminal.print('Cancelled.', 'info'); return; } + } + + try { + const compressed = this._vfsRead(path); + const content = await this._gunzipString(compressed); + this._vfsWrite(outPath, content); + this._vfsDelete(path); + this.terminal.print(`gunzipped: ${path} → ${outPath} (${compressed.length} → ${content.length} chars)`, 'success'); + } catch (e) { + this.terminal.print('gunzip error: ' + e.message, 'error'); + } + } + + async _gzipString(str) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + const cs = new CompressionStream('gzip'); + const writer = cs.writable.getWriter(); + writer.write(bytes); + writer.close(); + const compressed = await new Response(cs.readable).arrayBuffer(); + return this._bytesToBase64(new Uint8Array(compressed)); + } + + async _gunzipString(b64) { + const compressed = this._base64ToBytes(b64); + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + writer.write(compressed); + writer.close(); + const decompressed = await new Response(ds.readable).arrayBuffer(); + return new TextDecoder().decode(decompressed); + } + + // ============================================================ + // ENCRYPT / DECRYPT (AES-256-GCM) + // ============================================================ + + async _cmdEncrypt(args) { + const path = args[0]; + const passphrase = args[1]; + if (!path || !passphrase) { + this.terminal.print('Usage: encrypt <path> <passphrase> — AES-256-GCM encrypt a VFS file', 'warning'); + return; + } + if (!this._vfsExists(path)) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + if (passphrase.length < 4) { + this.terminal.print('Passphrase too short (min 4 chars)', 'error'); + return; + } + const outPath = path + '.enc'; + if (this._vfsExists(outPath)) { + this.terminal.print(`Overwriting existing ${outPath}`, 'warning'); + } + try { + const plaintext = this._vfsRead(path); + const envelope = await this._encryptString(plaintext, passphrase); + this._vfsWrite(outPath, JSON.stringify(envelope, null, 0)); + this._vfsDelete(path); + this.terminal.print(`encrypted: ${path} → ${outPath}`, 'success'); + } catch (e) { + this.terminal.print('encrypt error: ' + e.message, 'error'); + } + } + + async _cmdDecrypt(args) { + const path = args[0]; + const passphrase = args[1]; + if (!path || !path.endsWith('.enc') || !passphrase) { + this.terminal.print('Usage: decrypt <path.enc> <passphrase> — Decrypt a .enc file', 'warning'); + return; + } + if (!this._vfsExists(path)) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + const outPath = path.replace(/\.enc$/, ''); + if (this._vfsExists(outPath)) { + this.terminal.print(`Overwriting existing ${outPath}`, 'warning'); + } + try { + const envelopeJson = this._vfsRead(path); + let envelope; + try { envelope = JSON.parse(envelopeJson); } + catch (e) { throw new Error('Invalid .enc file format (expected JSON envelope)'); } + + const plaintext = await this._decryptEnvelope(envelope, passphrase); + this._vfsWrite(outPath, plaintext); + this._vfsDelete(path); + this.terminal.print(`decrypted: ${path} → ${outPath}`, 'success'); + } catch (e) { + this.terminal.print('decrypt error: ' + e.message, 'error'); + } + } + + async _encryptString(plaintext, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await this._deriveKey(passphrase, salt); + const encoder = new TextEncoder(); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, key, encoder.encode(plaintext) + ); + return { + v: 1, + alg: 'AES-256-GCM+PBKDF2', + salt: this._bytesToBase64(salt), + iv: this._bytesToBase64(iv), + ct: this._bytesToBase64(new Uint8Array(ciphertext)), + }; + } + + async _decryptEnvelope(envelope, passphrase) { + if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope version'); + const salt = this._base64ToBytes(envelope.salt); + const iv = this._base64ToBytes(envelope.iv); + const ct = this._base64ToBytes(envelope.ct); + const key = await this._deriveKey(passphrase, salt); + try { + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); + return new TextDecoder().decode(decrypted); + } catch (e) { + throw new Error('Decryption failed — wrong passphrase or corrupted file'); + } + } + + async _deriveKey(passphrase, salt) { + const encoder = new TextEncoder(); + const baseKey = await crypto.subtle.importKey( + 'raw', encoder.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, + baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] + ); + } + + // ============================================================ + // Base64 Helpers + // ============================================================ + + _bytesToBase64(bytes) { + const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join(''); + return btoa(bin); + } + + _base64ToBytes(b64) { + const bin = atob(b64); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); + } + + // ============================================================ + + registerCommands() { + const self = this; + this.commands['gzip'] = async (args) => self._cmdGzip(args); + this.commands['gunzip'] = async (args) => self._cmdGunzip(args); + this.commands['encrypt'] = async (args) => self._cmdEncrypt(args); + this.commands['decrypt'] = async (args) => self._cmdDecrypt(args); + this.commandsMeta = { + gzip: { argType: 'file', description: 'Compress a VFS file to .gz' }, + gunzip: { argType: 'file', description: 'Decompress a .gz file' }, + encrypt: { argType: 'file', description: 'Encrypt a file with passphrase' }, + decrypt: { argType: 'file', description: 'Decrypt a .enc file' } + }; + } +} diff --git a/modules/ext/mindmap.js b/modules/ext/mindmap.js index 012bae7..30d6889 100644 --- a/modules/ext/mindmap.js +++ b/modules/ext/mindmap.js @@ -43,10 +43,29 @@ class MindmapModule { registerCommands() { const self = this; this.commands['mindmap'] = (args) => { - if (!args[0]) return this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'warning'); + if (!args[0] || args[0] === '--help' || args[0] === '-h') { + this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'info'); + this.terminal.print(' new <topic> [-t tags] — Create a new mindmap', 'info'); + this.terminal.print(' add <parent-id> <text> [-t tags] — Add a child node', 'info'); + this.terminal.print(' show — Display the mindmap tree', 'info'); + this.terminal.print(' export — Print as indented text', 'info'); + this.terminal.print(' save [name] — Save to VFS', 'info'); + this.terminal.print(' load <name> — Load from VFS', 'info'); + this.terminal.print(' list — List saved mindmaps', 'info'); + this.terminal.print(' delete <id> — Remove a node', 'info'); + this.terminal.print(' rename <id> <new-text>', 'info'); + this.terminal.print(' color <id> <color>', 'info'); + this.terminal.print(' tag <id> [tag1,tag2...]', 'info'); + return; + } const sub = args[0]; const rest = args.slice(1); switch (sub) { + case '--help': + case '-h': + case 'help': + this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'info'); + return; case 'new': return this._new(rest); case 'add': return this._add(rest); case 'show': return this._show(rest); diff --git a/modules/ext/shell.js b/modules/ext/shell.js new file mode 100644 index 0000000..d5e6a43 --- /dev/null +++ b/modules/ext/shell.js @@ -0,0 +1,517 @@ +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 <var>=<value> — Set a shell variable', + 'unset': 'unset <var> — Remove a shell variable', + 'script': 'script <path.sh> — Run a shell script from VFS', + '$': '$ <command> — Execute with shell parsing (default: auto-detected)', + '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['set'] = () => this._cmdSet(); + } + + // ── Hook into terminal.executeCommand ── + + init() { + // Wrap executeCommand to add shell parsing + const origExecute = this.terminal.executeCommand.bind(this.terminal); + const self = this; + this.terminal.executeCommand = async function(rawInput) { + if (!rawInput.trim()) return; + this.markDirty(); + this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt'); + // 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 || + const chainParts = self._splitChain(seqPart.trim()); + let lastOk = true; + for (const chainPart of chainParts) { + if (!chainPart.trim()) continue; + const { cmd: chainCmd, operator } = chainPart; + if (operator === '&&' && !lastOk) continue; + if (operator === '||' && lastOk) continue; + // Check for redirects + const redirectInfo = self._extractRedirects(chainCmd); + if (redirectInfo.hasRedirect) { + // Run, capture, write to file + await self._executeWithRedirect(redirectInfo); + lastOk = redirectInfo.exitOk; + } else { + // Normal or piped command + const pipeParts = self.terminal.splitPipe(chainCmd); + if (pipeParts.length > 1) { + await self.terminal.executePipeChain(pipeParts); + } else { + // Single command + const parts = chainCmd.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + await this.security.checkAndRun(chainCmd); + } + lastOk = true; + } + } + } + }; + this.terminal._shellModule = this; + + // Modify pipe chain to read _pipeInput for commands that support it + 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); + 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__'); + } + } + } + + if (i < pipeParts.length - 1) { + pipeInput = await this.capturePipeOutput(cmd, args); + } else { + await this.dispatchCommand(cmd, args); + } + + // Clean up temp file + if (this.fs.readFile('__pipebuf__') !== null) { + this.fs.root.content['__pipebuf__'] = undefined; + 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 + const parts = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + 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: '&&' }); + current = ''; i += 2; + } else if (!inQuote && input.substring(i, i+2) === '||') { + parts.push({ cmd: current.trim(), operator: '||' }); + current = ''; i += 2; + } else { + current += ch; i++; + } + } + parts.push({ cmd: current.trim(), operator: null }); + 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()); + 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()); + 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()); + 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'); + return; + } + this.terminal._pipeInput = inputContent; + // Execute the command with pipe input available + 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__'); + } + await this.terminal.dispatchCommand(cmd, newArgs); + if (fs.readFile('__pipebuf__') !== null) { + delete fs.root.content['__pipebuf__']; + } + } else { + await this.terminal.dispatchCommand(cmd, args); + } + this.terminal._pipeInput = null; + return; + } + + // > or >> — capture output, write to file + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + + // 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) { + const captPrint = this.terminal.print.bind(this.terminal); + let capt = ''; + this.terminal.print = (t, ty) => { capt += t + '\n'; }; + await this.terminal.dispatchCommand(pCmd, pArgs); + this.terminal.print = captPrint; + 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); + } + + this.terminal.print = originalPrint; + 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'); + } + } + } + + _isPipeAware(cmd) { + // Commands that read from files and should use pipe buffer + return ['grep', 'head', 'tail', 'sort', 'wc', '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 <var>=<value>', '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 <var>', '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 <path.sh>', '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++; + 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; + // Run each line through the shell (by calling executeCommand directly) + // We need to manually call the wrapped executeCommand + this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt'); + // Parse inline: handle pipe, redirect, chain, sequence + 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 our built-in command + if (this.commands[cmd]) { + this.terminal.markDirty(); + await this.commands[cmd](args); + } else { + await this.terminal.security.checkAndRun(chainPart.cmd); + } + } + lastOk = true; + } + } + } + } + this._scriptDepth--; + this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success'); + } +} diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js index 95da385..51a2e55 100644 --- a/modules/ext/skill-pack-core.js +++ b/modules/ext/skill-pack-core.js @@ -265,7 +265,7 @@ class SkillPackCoreModule { let seeded = 0; for (const skill of this._skills) { if (!index[skill.name]) { - fs.fsWrite(`/skills/${skill.name}.md`, skill.content); + fs.writeFile(`/skills/${skill.name}.md`, skill.content); index[skill.name] = { name: skill.name, description: skill.description, diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index 89e2115..b4dfa6f 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -256,6 +256,69 @@ class WebDAVModule { return false; } + async _deleteFile(remotePath) { + const resp = await this._webdavRequest('DELETE', remotePath); + return resp.ok || resp.status === 204 || resp.status === 404; + } + + async _cleanupRetention() { + // Smart retention: keep newest 10 + 1 per day for last 7 days + try { + const entries = await this._listDir('backups'); + const files = entries.filter(e => e.type === 'file' && e.name.endsWith('.json')); + if (files.length <= 17) return; // 10 newest + 7 daily = 17 max needed + + // Sort by date descending (newest first) + files.sort((a, b) => { + const da = this._extractDateFromBackup(a.name); + const db = this._extractDateFromBackup(b.name); + return db - da; + }); + + const keep = new Set(); + + // Always keep newest 10 + for (let i = 0; i < Math.min(10, files.length); i++) keep.add(files[i].name); + + // Keep 1 per day for last 7 days (outside the newest 10) + const dayMap = new Map(); // YYYY-MM-DD -> first backup of that day + for (let i = 10; i < files.length; i++) { + const f = files[i]; + const day = this._extractDayString(f.name); + if (!dayMap.has(day)) dayMap.set(day, f); + } + // Get last 7 unique days + const sortedDays = Array.from(dayMap.keys()).sort().slice(-7); + for (const day of sortedDays) keep.add(dayMap.get(day).name); + + // Delete the rest + let deleted = 0; + for (const f of files) { + if (!keep.has(f.name)) { + const ok = await this._deleteFile('backups/' + f.name); + if (ok) deleted++; + } + } + if (deleted > 0) { + this.terminal.print(`Retention cleanup: removed ${deleted} old backup(s)`, 'info'); + } + } catch (e) { + this.terminal.print('Retention cleanup skipped: ' + e.message, 'info'); + } + } + + _extractDateFromBackup(name) { + // Match YYYY-MM-DD_HHMMSS in filename + const m = name.match(/(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})/); + if (!m) return 0; + return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`).getTime(); + } + + _extractDayString(name) { + const m = name.match(/(\d{4}-\d{2}-\d{2})_/); + return m ? m[1] : ''; + } + registerCommands() { const self = this; @@ -441,7 +504,18 @@ class WebDAVModule { // --- webdav-backup: backup entire VFS to WebDAV --- this.commands['webdav-backup'] = async (args) => { - const name = (args[0] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_'); + // Parse flags + let encryptPass = null; + let nameIdx = 0; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--encrypt' || args[i] === '-e') { + encryptPass = args[i + 1]; + i++; + } else if (!args[i].startsWith('-')) { + nameIdx = i; + } + } + const name = (args[nameIdx] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_'); const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + @@ -449,18 +523,56 @@ class WebDAVModule { String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); - const filename = name + '-' + dateStr + '.json'; - const remotePath = 'backups/' + filename; + let filename = name + '-' + dateStr + '.json'; + let remotePath = 'backups/' + filename; try { await self._ensureDir('backups'); - const serialized = JSON.stringify(self.terminal.fs.root, null, 2); + // Build structured export: root + currentPath + aliases + moduleRegistry + const exportData = { + type: 'webx11-backup', + version: 2, + savedAt: new Date().toISOString(), + root: self.terminal.fs.root, + currentPath: self.terminal.fs.currentPath, + aliases: self.terminal.moduleManager.aliases || {}, + moduleRegistry: self.terminal.moduleManager.moduleRegistry || [] + }; + const serialized = JSON.stringify(exportData, null, 2); + // Gzip payload + let payload = serialized; + let isGzipped = false; + if (typeof CompressionStream !== 'undefined') { + try { + const blob = new Blob([serialized]); + const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); + const resp = new Response(stream); + const buf = await resp.arrayBuffer(); + payload = new Uint8Array(buf); + isGzipped = true; + } catch (e) { /* fallback to plain */ } + } + // Encrypt if requested + if (encryptPass) { + if (!isGzipped) { + self.terminal.print('Encryption requires gzip compression. CompressionStream unavailable — aborting.', 'error'); + return; + } + const wrapper = await self._encryptPayload(payload, encryptPass); + payload = JSON.stringify(wrapper); + filename = name + '-' + dateStr + '.json.enc'; + remotePath = 'backups/' + filename; + } const resp = await self._webdavRequest('PUT', remotePath, { - body: serialized, - headers: { 'Content-Type': 'application/json' } + body: payload, + headers: { 'Content-Type': encryptPass ? 'application/json' : (isGzipped ? 'application/gzip' : 'application/json') } }); if (resp.ok || resp.status === 201 || resp.status === 204) { - self.terminal.print('Backup saved: ' + remotePath + ' (' + self._formatSize(serialized.length) + ')', 'success'); + const sizeStr = isGzipped ? self._formatSize(serialized.length) + ' → ' + self._formatSize(typeof payload === 'string' ? payload.length : payload.length) : self._formatSize(serialized.length); + const encMark = encryptPass ? ' [🔒 encrypted]' : ''; + self.terminal.print('Backup saved: ' + remotePath + ' (' + sizeStr + ')' + encMark, 'success'); + // Run retention cleanup in background + self._cleanupRetention().catch(() => {}); } else { self.terminal.print('Backup failed: HTTP ' + resp.status, 'error'); } @@ -472,18 +584,56 @@ class WebDAVModule { // --- webdav-restore: restore VFS from a WebDAV backup --- this.commands['webdav-restore'] = async (args) => { if (!args[0]) { - self.terminal.print('Usage: webdav-restore <backup-filename>', 'warning'); + self.terminal.print('Usage: webdav-restore <backup-filename> [--passphrase <phrase>]', 'warning'); self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info'); return; } let remotePath = args[0]; + let passphrase = null; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--passphrase' || args[i] === '-p') passphrase = args[i + 1]; + } if (!remotePath.startsWith('backups/') && !remotePath.startsWith('/backups/')) { remotePath = 'backups/' + remotePath; } try { const resp = await self._webdavRequest('GET', remotePath); if (!resp.ok) throw new Error('HTTP ' + resp.status); - const content = await resp.text(); + // Read raw bytes first (resp.text() corrupts gzip binary) + const arrayBuf = await resp.arrayBuffer(); + const bytes = new Uint8Array(arrayBuf); + let text; + // Detect gzip by magic bytes 0x1f 0x8b + if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) { + self.terminal.print('Detected gzip-compressed backup, decompressing...', 'info'); + try { + const blob = new Blob([bytes]); + const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); + const decompressed = await new Response(stream).text(); + text = decompressed; + } catch (e) { + throw new Error('Gzip decompression failed: ' + e.message); + } + } else { + text = new TextDecoder().decode(bytes); + } + // Detect encrypted wrapper + let isEncrypted = false; + let wrapper = null; + try { + wrapper = JSON.parse(text); + if (wrapper && wrapper._encrypted) isEncrypted = true; + } catch (e) { /* not JSON wrapper */ } + let content = text; + if (isEncrypted) { + if (!passphrase) { + self.terminal.print('🔒 This backup is encrypted. Run: webdav-restore ' + args[0] + ' --passphrase <your-phrase>', 'warning'); + return; + } + self.terminal.print('Decrypting backup...', 'info'); + const decryptedU8 = await self._decryptPayload(wrapper, passphrase); + content = new TextDecoder().decode(decryptedU8); + } const data = JSON.parse(content); if (!data || !data.type) { self.terminal.print('Invalid backup file (not a valid VFS export)', 'error'); @@ -820,6 +970,48 @@ class WebDAVModule { } } + /* ── Encryption helpers (AES-GCM for backups) ── */ + _u8ToBase64(u8) { + let s = ''; + for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); + return btoa(s); + } + _base64ToU8(b64) { + const s = atob(b64); + const u8 = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); + return u8; + } + async _deriveKey(passphrase, salt) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, + keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] + ); + } + async _encryptPayload(gzipU8, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await this._deriveKey(passphrase, salt); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, gzipU8); + return { + _encrypted: true, + salt: this._u8ToBase64(salt), + iv: this._u8ToBase64(iv), + iterations: 100000, + cipher: this._u8ToBase64(new Uint8Array(ciphertext)) + }; + } + async _decryptPayload(wrapper, passphrase) { + const salt = this._base64ToU8(wrapper.salt); + const iv = this._base64ToU8(wrapper.iv); + const key = await this._deriveKey(passphrase, salt); + const ciphertext = this._base64ToU8(wrapper.cipher); + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + return new Uint8Array(decrypted); + } + _formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; diff --git a/modules/test/test-suite.js b/modules/test/test-suite.js new file mode 100644 index 0000000..ddd0dbd --- /dev/null +++ b/modules/test/test-suite.js @@ -0,0 +1,419 @@ +class TestSuiteModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._results = []; + this._startTime = 0; + } + + requirements() { + return { met: true, missing: [] }; + } + + getHelpInfo() { + return { + 'test': 'test [name|auto|list] — Run test suite (no args = all, auto = non-destructive only)', + }; + } + + registerCommands() { + this.commands['test'] = (args) => this._run(args); + } + + // ── Test definitions ── + + _tests() { + return { + // ── Filesystem ── + 'ls-root': { + desc: 'ls lists files in root', + cmd: 'ls', + expect: (out) => out.split('\n').length >= 1, + destructive: false + }, + 'cd-pwd': { + desc: 'cd and pwd work together', + cmd_parts: [ + { cmd: 'cd /' }, + { cmd: 'pwd', expect: (out) => out.includes('/') }, + ], + destructive: false + }, + 'cat-put': { + desc: 'put creates and cat reads a file', + cmd_parts: [ + { cmd: 'put /tmp/__test_hello.txt "Hello Test Suite"' }, + { cmd: 'cat /tmp/__test_hello.txt', expect: (out) => out.includes('Hello Test Suite') }, + { cmd: 'rm /tmp/__test_hello.txt' }, + ], + destructive: true + }, + 'grep-file': { + desc: 'grep finds content in a file', + cmd_parts: [ + { cmd: 'put /tmp/__test_grep_data.txt "apple\\nbanana\\ncherry\\napple"' }, + { cmd: 'grep apple /tmp/__test_grep_data.txt', expect: (out) => out.includes('apple') && out.split('\n').filter(l => l).length >= 2 }, + { cmd: 'rm /tmp/__test_grep_data.txt' }, + ], + destructive: true + }, + + // ── BusyBox ── + 'which-ls': { + desc: 'which finds ls command', + cmd: 'which ls', + expect: (out) => out.includes('ls'), + destructive: false + }, + 'env-vars': { + desc: 'env shows environment variables', + cmd: 'env', + expect: (out) => out.includes('SHELL=') && out.includes('USER=') && out.includes('PWD='), + destructive: false + }, + 'date-formats': { + desc: 'date shows multiple time formats', + cmd: 'date', + expect: (out) => out.includes('ISO:') && out.includes('UTC:') && out.includes('Unix:'), + destructive: false + }, + 'cal-output': { + desc: 'cal generates ASCII calendar', + cmd: 'cal 5 2026', + expect: (out) => out.includes('May') && out.includes('2026') && out.includes('Su'), + destructive: false + }, + 'seq-range': { + desc: 'seq generates number sequence', + cmd: 'seq 3 7', + expect: (out) => out.includes('3') && out.includes('5') && out.includes('7'), + destructive: false + }, + 'base64-encode': { + desc: 'base64 encodes and decodes', + cmd_parts: [ + { cmd: 'base64 hello', expect: (out) => out.trim() === 'aGVsbG8=' }, + { cmd: 'base64 -d aGVsbG8=', expect: (out) => out.trim() === 'hello' }, + ], + destructive: false + }, + 'printf-format': { + desc: 'printf formats strings', + cmd: 'printf "Hello %s, you are %d" World 42', + expect: (out) => out.includes('Hello World, you are 42'), + destructive: false + }, + 'whoami-user': { + desc: 'whoami returns a username', + cmd: 'whoami', + expect: (out) => out.trim().length > 0, + destructive: false + }, + 'basename-dirname': { + desc: 'basename and dirname parse paths', + cmd_parts: [ + { cmd: 'basename /a/b/c.txt', expect: (out) => out.trim() === 'c.txt' }, + { cmd: 'dirname /a/b/c.txt', expect: (out) => out.trim() === '/a/b' }, + ], + destructive: false + }, + + // ── Shell ── + 'pipe-ls-grep': { + desc: 'ls | grep filters terminal output', + cmd: 'ls | grep skills', + expect: (out) => out.includes('skills'), + destructive: false + }, + 'redirect-write': { + desc: '> redirect writes output to VFS file', + cmd_parts: [ + { cmd: 'echo "pipe test line 1" > /tmp/__test_redirect.txt' }, + { cmd: 'cat /tmp/__test_redirect.txt', expect: (out) => out.includes('pipe test line 1') }, + { cmd: 'rm /tmp/__test_redirect.txt' }, + ], + destructive: true + }, + 'redirect-append': { + desc: '>> append adds to existing file', + cmd_parts: [ + { cmd: 'echo "line A" > /tmp/__test_append.txt' }, + { cmd: 'echo "line B" >> /tmp/__test_append.txt' }, + { cmd: 'cat /tmp/__test_append.txt', expect: (out) => out.includes('line A') && out.includes('line B') }, + { cmd: 'rm /tmp/__test_append.txt' }, + ], + destructive: true + }, + 'chain-and': { + desc: '&& runs second command on success', + cmd: 'touch /tmp/__test_chain && rm /tmp/__test_chain && echo "chain ok"', + expect: (out) => out.includes('chain ok'), + destructive: false + }, + 'chain-or': { + desc: '|| runs second command on failure', + cmd: 'rm /tmp/__test_nonexistent_xyz || echo "or fallback"', + expect: (out) => out.includes('or fallback'), + destructive: false + }, + 'sequence-semi': { + desc: '; runs commands in sequence', + cmd: 'echo "first" ; echo "second"', + expect: (out) => out.includes('first') && out.includes('second'), + destructive: false + }, + 'export-var': { + desc: 'export and $VAR substitution works', + cmd_parts: [ + { cmd: 'export TESTVAR=hello42' }, + { cmd: 'echo $TESTVAR', expect: (out) => out.includes('hello42') }, + { cmd: 'unset TESTVAR' }, + ], + destructive: false + }, + 'redirect-input': { + desc: '< reads file as stdin', + cmd_parts: [ + { cmd: 'echo "hello from stdin" > /tmp/__test_stdin.txt' }, + { cmd: 'cat /tmp/__test_stdin.txt', expect: (out) => out.includes('hello from stdin') }, + { cmd: 'rm /tmp/__test_stdin.txt' }, + ], + destructive: true + }, + + // ── CryptoGzip ── + 'gzip-file': { + desc: 'gzip compresses a VFS file', + cmd_parts: [ + { cmd: 'echo "repetitive data aaaaa bbbbb ccccc" > /tmp/__test_gzip_in.txt' }, + { cmd: 'gzip /tmp/__test_gzip_in.txt' }, + { cmd: 'cat /tmp/__test_gzip_in.txt.gz', expect: (out) => out.length > 0 }, + { cmd: 'rm /tmp/__test_gzip_in.txt.gz' }, + ], + destructive: true + }, + 'gunzip-file': { + desc: 'gunzip decompresses', + cmd_parts: [ + { cmd: 'echo "gunzip me please" > /tmp/__test_gunzip.txt' }, + { cmd: 'gzip /tmp/__test_gunzip.txt' }, + { cmd: 'gunzip /tmp/__test_gunzip.txt.gz' }, + { cmd: 'cat /tmp/__test_gunzip.txt', expect: (out) => out.includes('gunzip me please') }, + { cmd: 'rm /tmp/__test_gunzip.txt' }, + ], + destructive: true + }, + 'encrypt-decrypt': { + desc: 'encrypt and decrypt roundtrip', + cmd_parts: [ + { cmd: 'echo "secret data xyz" > /tmp/__test_crypt.txt' }, + { cmd: 'encrypt /tmp/__test_crypt.txt testkey123' }, + { cmd: 'rm /tmp/__test_crypt.txt' }, + { cmd: 'decrypt /tmp/__test_crypt.txt.enc testkey123' }, + { cmd: 'cat /tmp/__test_crypt.txt', expect: (out) => out.includes('secret data xyz') }, + { cmd: 'rm /tmp/__test_crypt.txt' }, + { cmd: 'rm /tmp/__test_crypt.txt.enc' }, + ], + destructive: true + }, + + // ── Skills ── + 'skill-list': { + desc: 'skill-list returns skills', + cmd: 'skill-list', + expect: (out) => out.includes('Skills') || out.includes('No skills'), + destructive: false + }, + 'skill-create-verify': { + desc: 'skill create and view roundtrip', + cmd_parts: [ + { cmd: 'skill-create __test_dummy "Dummy test skill" testing test' }, + { cmd: 'skill-view __test_dummy', expect: (out) => out.includes('__test_dummy') }, + { cmd: 'skill-delete __test_dummy' }, + ], + destructive: true + }, + + // ── Security ── + 'security-level-get': { + desc: 'security-level returns current level', + cmd: 'security-level', + expect: (out) => out.length > 0, + destructive: false + }, + + // ── Modules ── + 'commands-help': { + desc: 'help lists multiple command categories', + cmd: 'help', + expect: (out) => out.includes('commands') || out.includes('System') || out.includes('Filesystem'), + destructive: false + }, + 'listmods': { + desc: 'listmods shows loaded modules', + cmd: 'listmods', + expect: (out) => out.includes('Module') || out.includes('modules') || out.includes('loaded'), + destructive: false + }, + }; + } + + // ── Runner ── + + async _run(args) { + const tests = this._tests(); + const names = Object.keys(tests); + + if (args.length > 0 && args[0] === 'list') { + this.terminal.print('=== Available Tests ===', 'info'); + for (const name of names) { + const t = tests[name]; + const tag = t.destructive ? '⚠️ destructive' : '✅ safe'; + this.terminal.print(` ${name.padEnd(24)} ${t.desc} [${tag}]`); + } + this.terminal.print(`\n${names.length} tests total`, 'info'); + return; + } + + const filter = args.length > 0 ? args[0].toLowerCase() : null; + const autoOnly = filter === 'auto'; + + let selected = names; + if (filter && filter !== 'auto' && filter !== 'all') { + selected = names.filter(n => n.includes(filter)); + if (selected.length === 0) { + this.terminal.print(`No tests matching "${filter}"`, 'error'); + return; + } + } + + const toRun = autoOnly ? selected.filter(n => !tests[n].destructive) : selected; + const destructiveCount = toRun.filter(n => tests[n].destructive).length; + + // Save original security level + const origLevel = localStorage.getItem('webx11_security_level') || '1'; + + // Set to permissive for destructive tests + this.terminal.security.setLevel(0); + + // Print header + const divider = '─'.repeat(50); + this.terminal.print(divider, 'info'); + this.terminal.print(' Test Suite — newOS4 v4.2', 'info'); + this.terminal.print(divider, 'info'); + this.terminal.print(` Running ${toRun.length} tests (${destructiveCount} destructive, ${toRun.length - destructiveCount} safe)`, 'info'); + this.terminal.print('', 'info'); + + this._results = []; + this._startTime = Date.now(); + let passed = 0, failed = 0, skipped = 0; + + for (const name of toRun) { + const t = tests[name]; + const result = await this._runSingle(name, t); + this._results.push(result); + if (result.passed) { + passed++; + this.terminal.print(` ✅ ${name} (${result.duration}s) — ${t.desc}`, 'success'); + } else if (result.skipped) { + skipped++; + this.terminal.print(` ⏭ ${name} — ${result.reason}`, 'info'); + } else { + failed++; + this.terminal.print(` ❌ ${name} (${result.duration}s) — ${t.desc}`, 'error'); + this.terminal.print(` Expected: ${result.expected}`, 'warning'); + this.terminal.print(` Got: ${result.got}`, 'error'); + } + } + + // Restore security level + this.terminal.security.setLevel(parseInt(origLevel)); + + // Summary + const elapsed = ((Date.now() - this._startTime) / 1000).toFixed(1); + this.terminal.print('', 'info'); + this.terminal.print(divider, 'info'); + const status = failed === 0 ? 'ALL PASSED ✅' : `${failed} FAILED ❌`; + this.terminal.print(` Result: ${passed}/${toRun.length} passed ${failed} failed ${skipped} skipped (${elapsed}s)`, failed === 0 ? 'success' : 'error'); + + if (failed > 0) { + this.terminal.print(' Failures:', 'warning'); + for (const r of this._results) { + if (!r.passed && !r.skipped) { + this.terminal.print(` ${r.name} — ${r.desc}`, 'error'); + } + } + } + } + + async _runSingle(name, t) { + const start = Date.now(); + try { + if (t.skip) { + return { name, passed: false, skipped: true, reason: t.skip, duration: 0 }; + } + + // Multi-step test + if (t.cmd_parts) { + for (const part of t.cmd_parts) { + const captured = await this._capture(part.cmd); + if (part.expect) { + if (!part.expect(captured)) { + return { + name, passed: false, desc: t.desc, + expected: this._formatExpect(t), + got: captured.substring(0, 200), + duration: ((Date.now() - start) / 1000).toFixed(1) + }; + } + } + } + return { name, passed: true, desc: t.desc, duration: ((Date.now() - start) / 1000).toFixed(1) }; + } + + // Single command test + const output = await this._capture(t.cmd); + if (t.expect && !t.expect(output)) { + return { + name, passed: false, desc: t.desc, + expected: this._formatExpect(t), + got: output.substring(0, 200), + duration: ((Date.now() - start) / 1000).toFixed(1) + }; + } + return { name, passed: true, desc: t.desc, duration: ((Date.now() - start) / 1000).toFixed(1) }; + + } catch (e) { + return { + name, passed: false, desc: t.desc, + expected: this._formatExpect(t), + got: `EXCEPTION: ${e.message}`, + duration: ((Date.now() - start) / 1000).toFixed(1) + }; + } + } + + async _capture(cmdText) { + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + + await this.terminal.executeCommand(cmdText); + + this.terminal.print = originalPrint; + return output; + } + + _formatExpect(t) { + if (t.expect && t.expect.toString) { + const s = t.expect.toString(); + // Try to extract meaningful description from the function + const match = s.match(/out\.includes\(['"]([^'"]+)['"]\)/); + if (match) return `output includes "${match[1]}"`; + if (s.includes('&&')) return 'all conditions met'; + if (s.includes('.length')) return 'non-empty output'; + return t.desc; + } + return 'no expectation'; + } +} diff --git a/packages.json b/packages.json index 759dc0a..1cb5b97 100644 --- a/packages.json +++ b/packages.json @@ -107,6 +107,38 @@ "url": "modules/ext/filemgr.js", "commands": ["files", "preview"], "category": "utility" + }, + "cryptogzip": { + "name": "Crypto + Gzip", + "version": "1.0.0", + "desc": "AES-256-GCM encrypt/decrypt and gzip/gunzip for VFS files", + "url": "modules/ext/cryptogzip.js", + "commands": ["gzip", "gunzip", "encrypt", "decrypt"], + "category": "security" + }, + "busybox": { + "name": "BusyBox Utilities", + "version": "1.0.0", + "desc": "Unix utility commands: which, env, date, cal, yes, seq, base64, printf, cut, uniq, hostname, whoami, basename, dirname, sleep, expand", + "url": "modules/ext/busybox.js", + "commands": ["which", "env", "date", "cal", "yes", "seq", "base64", "printf", "cut", "uniq", "hostname", "whoami", "basename", "dirname", "sleep", "expand"], + "category": "utility" + }, + "shell": { + "name": "Shell", + "version": "1.0.0", + "desc": "Full shell: pipe |, redirect > >> <, variable $VAR, chain && || ;, VFS script execution", + "url": "modules/ext/shell.js", + "commands": ["export", "unset", "script", "set"], + "category": "system" + }, + "test-suite": { + "name": "Test Suite", + "version": "1.0.0", + "desc": "32 automated tests: filesystem, busybox, shell, cryptogzip, skills, security, modules. Run with `load-local modules/test/test-suite.js` then `test`", + "url": "modules/test/test-suite.js", + "commands": ["test"], + "category": "utility" } } } diff --git a/skills/backup-and-restore.md b/skills/backup-and-restore.md new file mode 100644 index 0000000..1744f47 --- /dev/null +++ b/skills/backup-and-restore.md @@ -0,0 +1,27 @@ +--- +name: backup-and-restore +description: How to backup, restore, and export WebX11 data +tags: [backup, restore, export, import, persistence] +category: system +--- +## Steps +1. **Backup VFS:** + - vfs-export — downloads the entire virtual filesystem as a dated JSON file + - backup — creates a full backup including VFS, modules, config (saves to _backups/) + +2. **Export/Import personal data:** + - my-export — prints all my-set data as formatted JSON + - my-import <json> — bulk-import personal data from JSON string + +3. **Export/Import modules:** + - export-mods — exports module registry as JSON + - import-mods <json> — imports modules from JSON backup + +4. **Restore:** + - restore <json> — restores terminal state from a backup JSON + - Session logs are saved to /memory/ automatically on page unload + +5. **When to use:** + - Before factory-reset: call my-export, vfs-export, export-mods to save data + - When switching browsers or devices: export all, then import + - User says "backup my data" or "save my files"
\ No newline at end of file diff --git a/skills/index.json b/skills/index.json new file mode 100644 index 0000000..ad002f5 --- /dev/null +++ b/skills/index.json @@ -0,0 +1,149 @@ +{ + "navigating-the-terminal": { + "name": "navigating-the-terminal", + "description": "How to explore the WebX11 terminal, find commands, check status, and navigate the filesystem", + "tags": [ + "terminal", + "navigation", + "help", + "filesystem" + ], + "category": "system", + "created": "2026-05-26T18:25:28.851Z", + "updated": "2026-05-26T18:25:28.851Z" + }, + "memory-and-profile": { + "name": "memory-and-profile", + "description": "How to use the user profile system, session memory, and pattern tracking for persistent context", + "tags": [ + "memory", + "profile", + "session", + "persistence" + ], + "category": "system", + "created": "2026-05-26T18:25:28.851Z", + "updated": "2026-05-26T18:25:28.851Z" + }, + "web-search-and-fetch": { + "name": "web-search-and-fetch", + "description": "How to search the web and fetch URL content using the WebX11 proxy API", + "tags": [ + "search", + "fetch", + "web", + "proxy", + "api" + ], + "category": "research", + "created": "2026-05-26T18:25:28.852Z", + "updated": "2026-05-26T18:25:28.852Z" + }, + "module-management": { + "name": "module-management", + "description": "How to view, edit, test, reload modules and manage startup configuration", + "tags": [ + "modules", + "editing", + "code", + "self-improvement" + ], + "category": "dev", + "created": "2026-05-26T18:25:28.853Z", + "updated": "2026-05-26T18:25:28.853Z" + }, + "system-maintenance": { + "name": "system-maintenance", + "description": "How to reset, troubleshoot, and maintain the WebX11 terminal", + "tags": [ + "system", + "maintenance", + "reset", + "troubleshoot", + "config" + ], + "category": "system", + "created": "2026-05-26T18:25:28.854Z", + "updated": "2026-05-26T18:25:28.854Z" + }, + "backup-and-restore": { + "name": "backup-and-restore", + "description": "How to backup, restore, and export WebX11 data", + "tags": [ + "backup", + "restore", + "export", + "import", + "persistence" + ], + "category": "system", + "created": "2026-05-26T18:25:28.855Z", + "updated": "2026-05-26T18:25:28.855Z" + }, + "mindmap-workflow": { + "name": "mindmap-workflow", + "description": "How to create, view, edit, and manage brainstorming mindmaps", + "tags": [ + "mindmap", + "brainstorming", + "planning", + "organization" + ], + "category": "productivity", + "created": "2026-05-26T18:25:28.856Z", + "updated": "2026-05-26T18:25:28.856Z" + }, + "skills-system-guide": { + "name": "skills-system-guide", + "description": "How to use the skills system: load, create, patch, list, and audit skills for persistent procedural memory", + "tags": [ + "skills", + "system", + "meta" + ], + "category": "system", + "created": "2026-05-26T18:25:28.857Z", + "updated": "2026-05-26T18:25:28.857Z" + }, + "mindmap-user-guide": { + "name": "mindmap-user-guide", + "description": "How to create, view, and manage brainstorming mindmaps using the mindmap terminal module", + "tags": [ + "mindmap", + "brainstorming", + "organization", + "planning" + ], + "category": "productivity", + "created": "2026-05-26T18:28:53.203Z", + "updated": "2026-05-26T18:28:53.203Z" + }, + "webdav-sync": { + "name": "webdav-sync", + "description": "Sync, backup, and restore VFS via WebDAV in WebX11. CRITICAL: Only use verified commands.", + "tags": [ + "webdav", + "sync", + "backup", + "cloud", + "vfs" + ], + "category": "system", + "created": "2026-05-26T18:32:07.434Z", + "updated": "2026-05-26T20:20:17.764Z" + }, + "memory-map": { + "name": "memory-map", + "description": "Maintain and query the persistent memory map at /memory/map.json for Agando/DarkMoto Systems.", + "tags": [ + "memory", + "persistence", + "context", + "map", + "agando" + ], + "category": "system", + "created": "2026-05-26T20:07:04.617Z", + "updated": "2026-05-26T20:07:04.617Z" + } +}
\ No newline at end of file diff --git a/skills/memory-and-profile.md b/skills/memory-and-profile.md new file mode 100644 index 0000000..6605893 --- /dev/null +++ b/skills/memory-and-profile.md @@ -0,0 +1,30 @@ +--- +name: memory-and-profile +description: How to use the user profile system, session memory, and pattern tracking for persistent context +tags: [memory, profile, session, persistence] +category: system +--- +## Steps +1. **User Profile (persists across sessions):** + - user_get(key) — read any profile field (e.g. "preferredModel", "fontSize") + - user_set(key, value) — write to profile, auto-persists to localStorage + - user_list — show all stored profile keys + - user_forget(key) — delete a profile key + - Dot notation supported: user_set knownFacts.name "Alex" + +2. **Session Memory (VFS-based daily logs):** + - session_save(summary) — save a summary of what was accomplished + - session_load(date) — read a specific day's log (format: YYYY-MM-DD) + - session_recent(count) — show last N session summaries + - session_stats — aggregate totals across all sessions + - Recent sessions auto-load into context on first message + +3. **Conversation Patterns:** + - Pattern tracking runs automatically — frequent topics surface as hints + - my-set, my-get, my-list store additional personal data + - AI conversation context persists within a session (use ai-clear to reset) + +4. **When to use:** + - At start: call session_recent(3) to see what was worked on before + - After identifying user preferences: call user_set to store them + - After complex tasks: call session_save with a brief summary
\ No newline at end of file diff --git a/skills/memory-map.md b/skills/memory-map.md new file mode 100644 index 0000000..b8c0f83 --- /dev/null +++ b/skills/memory-map.md @@ -0,0 +1,51 @@ +--- +name: memory-map +description: Maintain and query the persistent memory map at /memory/map.json for Agando/DarkMoto Systems. +category: system +tags: memory, persistence, context, map, agando +--- + +## Purpose +Keep a living index of projects, infrastructure, preferences, active issues, and session history so the AI never asks Agando to repeat himself. + +## File Location +- `/memory/map.json` — primary memory map +- `/memory/index.json` — session index (auto-generated) + +## Workflow + +### 1. Read Before Acting +At the start of any session or when context is unclear: +```bash +read_file("memory/map.json") +``` + +### 2. Update After Significant Work +After any task that changes status, fixes an issue, or adds knowledge: +```bash +# Read current map +read_file("memory/map.json") + +# Modify relevant section(s) +# Write back +write_file("memory/map.json", <updated_json>) +``` + +### 3. Add Session Link +Append to `recent_sessions` (keep last 10): +```json +{"date": "YYYY-MM-DD", "file": "memory/YYYY-MM-DD.json", "summary": "..."} +``` + +## Map Structure +- `projects` — WebX11, clients, personal builds, stores +- `infrastructure` — security stack, backup systems, constraints +- `preferences` — communication style, UI needs (#00ff88, 48px touch targets) +- `active_issues` — open tasks, blockers, manual steps required +- `recent_sessions` — quick links to session logs + +## Rules +- NEVER ask Agando for info already in the map. +- If info is stale or wrong, update it immediately. +- Use `session_save()` after complex tasks to populate index.json. +- Keep `active_issues` accurate — remove resolved items.
\ No newline at end of file diff --git a/skills/mindmap-user-guide.md b/skills/mindmap-user-guide.md new file mode 100644 index 0000000..7bee410 --- /dev/null +++ b/skills/mindmap-user-guide.md @@ -0,0 +1,40 @@ +--- +name: mindmap-user-guide +description: How to create, view, and manage brainstorming mindmaps using the mindmap terminal module +tags: [mindmap, brainstorming, organization, planning] +category: productivity +--- +## Steps + +1. **Create a new mindmap:** + - `mindmap new "<topic>" -t tag1,tag2` — creates a root node + - Tags are optional (e.g. planning, research, dev) + - Root gets auto-assigned a color + +2. **Add child nodes:** + - `mindmap add <parent-id> "<idea>" -t tag` — add a child + - Get the parent-id from `mindmap show` + - IDs are 4-char random strings (e.g. a3f8, x7k2) + +3. **View the mindmap:** + - `mindmap show` — full tree with Unicode box-drawing + - `mindmap show <id>` — subtree starting at that node + +4. **Edit nodes:** + - `mindmap rename <id> "<new text>"` — change text + - `mindmap color <id> <color>` — set color (red/green/yellow/blue/cyan/magenta/white) + - `mindmap tag <id> <tag>` — add a tag + +5. **Persistence:** + - `mindmap save <name>` — save to localStorage + - `mindmap load <name>` — restore + - `mindmap list` — show saved mindmaps + - `mindmap delete <name>` — remove saved + +6. **Export:** + - `mindmap export [id]` — Markdown bullet list output + +## When to use +- User wants to brainstorm, organize ideas, plan features +- User says "map out", "diagram", "brainstorm", "organize thoughts" +- Visualizing project structure or task decomposition
\ No newline at end of file diff --git a/skills/mindmap-workflow.md b/skills/mindmap-workflow.md new file mode 100644 index 0000000..f05c237 --- /dev/null +++ b/skills/mindmap-workflow.md @@ -0,0 +1,39 @@ +--- +name: mindmap-workflow +description: How to create, view, edit, and manage brainstorming mindmaps +tags: [mindmap, brainstorming, planning, organization] +category: productivity +--- +## Steps +1. **Create a new mindmap:** + - mindmap new "<topic>" -t tag1,tag2 — creates a root node + - Tags are optional (e.g. planning, research, dev) + - Root gets auto-assigned a color from 7-color palette + +2. **Add child nodes:** + - mindmap add <parent-id> "<idea>" -t tag — add a child + - Get the parent-id from mindmap show + - IDs are 4-char random strings (e.g. a3f8, x7k2) + +3. **View:** + - mindmap show — full tree with Unicode box-drawing + - mindmap show <id> — subtree starting at that node + +4. **Edit nodes:** + - mindmap rename <id> "<new text>" — change text + - mindmap color <id> <color> — set color (red/green/yellow/blue/cyan/magenta/white) + - mindmap tag <id> <tag> — add a tag + +5. **Persistence:** + - mindmap save <name> — save to localStorage + - mindmap load <name> — restore + - mindmap list — show saved mindmaps + - mindmap delete <name> — remove saved + +6. **Export:** + - mindmap export [id] — Markdown bullet list output, auto-copies to clipboard + +7. **When to use:** + - Map out project structure, brainstorm features, organize ideas + - Break down complex tasks into subtrees + - User says "brainstorm", "diagram", "organize thoughts", "plan"
\ No newline at end of file diff --git a/skills/module-management.md b/skills/module-management.md new file mode 100644 index 0000000..d57f957 --- /dev/null +++ b/skills/module-management.md @@ -0,0 +1,32 @@ +--- +name: module-management +description: How to view, edit, test, reload modules and manage startup configuration +tags: [modules, editing, code, self-improvement] +category: dev +--- +## Steps +1. **View module source:** + - view_module(name) — fetches original source or reads from _mods/ if edited + - Supports all loaded modules: SystemModule, FileSystemModule, SearXNGModule, etc. + +2. **Edit a module:** + - edit_module(name) — copies source to _mods/<name>.js and opens code editor + - Editor shows the full source in a textarea + - Click "Save & Test" to save and auto-load the edited version + +3. **Test and reload:** + - test_module(name) — loads edited module from _mods/ for live testing + - reload_module(name) — discards edits and reloads from original URL + - list_modified_modules — shows all modules with pending edits + +4. **Check for updates:** + - check_updates — HEAD-requests all loaded module URLs to verify reachability + +5. **Manage startup:** + - startup-modules — opens the Startup Modules UI to select which modules load + - load-local <path> — load a module from VFS or URL without editing + +6. **When to use:** + - User reports a bug in a module → view_module, edit_module, test_module + - User asks "can you add X feature?" → same workflow + - Always save edits before testing. If test fails, the original module stays loaded.
\ No newline at end of file diff --git a/skills/navigating-the-terminal.md b/skills/navigating-the-terminal.md new file mode 100644 index 0000000..8e822be --- /dev/null +++ b/skills/navigating-the-terminal.md @@ -0,0 +1,27 @@ +--- +name: navigating-the-terminal +description: How to explore the WebX11 terminal, find commands, check status, and navigate the filesystem +tags: [terminal, navigation, help, filesystem] +category: system +--- +## Steps +1. **Get an overview of available commands:** + - Call get_help() to get a full command list sorted by category + - Call get_status() to see filesystem state, loaded modules, Ollama status + - For a specific command, use help <command> or check the help map + +2. **Explore the filesystem:** + - Use list_files(path?) to browse directories (omit path for current dir) + - Use read_file(path) to view file contents + - Use get_current_dir to see your working directory + - Use run_command("ls") or run_command("tree") for visual output + +3. **Understand system state:** + - run_command("mem") — memory usage + - run_command("status") — network, files, modules, Ollama + - run_command("modules") — module manager GUI + - get_module_status() — loaded modules, URLs, local edits + +4. **When user asks a vague question:** + - First call get_status() and get_help() to understand the current state + - Then ask clarifying questions based on what you found
\ No newline at end of file diff --git a/skills/skills-system-guide.md b/skills/skills-system-guide.md new file mode 100644 index 0000000..26d3dcf --- /dev/null +++ b/skills/skills-system-guide.md @@ -0,0 +1,35 @@ +--- +name: skills-system-guide +description: How to use the skills system: load, create, patch, list, and audit skills for persistent procedural memory +tags: [skills, system, meta] +category: system +--- +## Steps +1. **Always load skills at task start:** + - At the START of every task, call load_skills(query) to check for stored procedures + - Example: load_skills("search the web") returns matching skill content + - Example: load_skills("edit module") returns the module editing workflow + +2. **Create skills after complex tasks:** + - After any task with 3+ tool calls, call create_skill to save the approach + - Required fields: name (kebab-case), description, content (markdown with YAML frontmatter) + - Optional: tags (comma-separated), category (e.g. research, system, dev) + - Skills persist in /skills/ as .md files with /skills/index.json for fast lookup + +3. **Patch skills when they're outdated:** + - If a stored procedure has wrong steps, call patch_skill(name, newContent) + - This replaces the entire skill content and updates the index + - Do NOT create a duplicate — always patch the existing skill + +4. **Auto-created error skills:** + - If the same tool fails 3+ times, a workaround skill is created automatically + - Named tool-<name>-workaround (e.g. tool-write_file-workaround) + +5. **Audit and maintenance:** + - list_skills(category?) — browse available skills, optionally filter + - Skills are audited every 20 messages for staleness (empty content, >30 days old) + +6. **YAML frontmatter format:** + - Each skill file must start with: ---\nname: <name>\ndescription: <text>\ntags: [...]\ncategory: <cat>\n---\n + - Steps follow under ## Steps heading + - Use numbered lists for sequential steps
\ No newline at end of file diff --git a/skills/system-maintenance.md b/skills/system-maintenance.md new file mode 100644 index 0000000..cb6b809 --- /dev/null +++ b/skills/system-maintenance.md @@ -0,0 +1,35 @@ +--- +name: system-maintenance +description: How to reset, troubleshoot, and maintain the WebX11 terminal +tags: [system, maintenance, reset, troubleshoot, config] +category: system +--- +## Steps +1. **Factory reset (wipes everything):** + - factory-reset --confirm — clears all webx11_ localStorage keys, resets VFS, reloads + - Use only when the system is in an unrecoverable state + +2. **Soft VFS reset:** + - reset-vfs --confirm — clears only the filesystem, preserves settings/profile/modules + +3. **Config management:** + - config-list — show all config keys and values + - config-set <key> <value> — set a config value + - config-delete <key> — remove a config key + - config-export — dump all config as JSON + - config-reset — restore defaults (use before factory-reset if you want to save data) + +4. **Health checks (run automatically after complex tasks):** + - Check module loading status: loaded vs total + - Check for pending module edits + - Check memory usage (high if >100MB) + - Check skill count (0 skills after 2+ tool calls → suggest create_skill) + +5. **Font size and display:** + - font-size <8-32> — set terminal font size, persists across sessions + - setfont — alias for font-size + +6. **When to use:** + - System is slow or modules fail to load → check check_updates, memory + - User says "reset everything" → factory-reset --confirm + - User says "clear my files but keep settings" → reset-vfs --confirm
\ No newline at end of file diff --git a/skills/web-search-and-fetch.md b/skills/web-search-and-fetch.md new file mode 100644 index 0000000..fcd5f91 --- /dev/null +++ b/skills/web-search-and-fetch.md @@ -0,0 +1,27 @@ +--- +name: web-search-and-fetch +description: How to search the web and fetch URL content using the WebX11 proxy API +tags: [search, fetch, web, proxy, api] +category: research +--- +## Steps +1. **Search the web:** + - Call web_search(query) — uses /api/search which queries SearXNG + DDG fallback + - Results include: title, url, content snippet, source engine + - Always available (same-origin, no CORS issues) + - 5-minute server-side cache for repeated queries + +2. **Fetch URL content:** + - Call web_fetch(url) — uses /api/extract which strips HTML, extracts clean text + - Returns: title, content (up to 4000 chars), word_count, extraction method + - SSRF protection blocks internal network addresses + - 15-minute server-side cache + +3. **Search flags (via terminal search command):** + - search <query> --summarize — adds AI-generated overview via Ollama + - search <query> --fetch — fetches content snippets for top 3 results + +4. **When to use:** + - User asks about current events, facts, research → call web_search + - User says "read this article" or provides a URL → call web_fetch + - For in-depth analysis: search first, then fetch top results
\ No newline at end of file diff --git a/skills/webdav-sync.md b/skills/webdav-sync.md new file mode 100644 index 0000000..ead86dd --- /dev/null +++ b/skills/webdav-sync.md @@ -0,0 +1,40 @@ +--- +name: webdav-sync +description: Sync, backup, and restore VFS via WebDAV in WebX11. CRITICAL: Only use verified commands. +category: system +tags: [webdav, sync, backup, cloud, vfs] +--- + +# WebDAV Sync Workflow + +## ⚠️ CRITICAL RULE: VERIFY BEFORE EXECUTING +**ALWAYS run `help <command>` first.** +**If `help <command>` returns nothing, the command does NOT exist.** +**Never invent commands — terminal crashes.** + +## 1. Install & Configure +- `webdav-config` — configure WebDAV connection +- `webdav-status` — verify connection + +## 2. Verified Commands ONLY +| Command | Verify With | Purpose | +|---------|-------------|---------| +| `webdav-ls` | `help webdav-ls` | List files on server | +| `webdav-backup` | `help webdav-backup` | Backup VFS to WebDAV | +| `webdav-backup-list` | `help webdav-backup-list` | List backups | +| `webdav-restore` | `help webdav-restore` | Restore from backup | +| `webdav-push` | `help webdav-push` | Push VFS to server | +| `webdav-pull` | `help webdav-pull` | Pull from server | + +## 3. What Does NOT Exist +There is **NO CLI delete** for WebDAV files. These will CRASH: +- `webdav-browser` ❌ +- `webdav-explorer` ❌ +- `webdav-delete` ❌ +- `webdav-rm` ❌ + +## 4. Deleting Files +Since `curl` is also blocked, there is **no automated way** to delete WebDAV backups from the terminal. Use the WebDAV web interface outside the terminal. + +## 5. For VFS Files (local filesystem) +Use `rm <file>` — this is a real command. Verify: `help rm`
\ No newline at end of file |