aboutsummaryrefslogtreecommitdiff
path: root/modules/ext
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-05-27 06:00:43 +0200
committerAgando <roduccomm@gmail.com>2026-05-27 06:00:43 +0200
commit212708b464c2bdbdd9670c00804623dca0729d2c (patch)
treeee190cbb0d1524350baea641df65aa19817744b5 /modules/ext
parentf4d934d341056142da1212c845eeeb170fd452c2 (diff)
auto: 2026-05-27_06:00 changes
Diffstat (limited to 'modules/ext')
-rw-r--r--modules/ext/busybox.js407
-rw-r--r--modules/ext/cryptogzip.js281
-rw-r--r--modules/ext/mindmap.js21
-rw-r--r--modules/ext/shell.js517
-rw-r--r--modules/ext/skill-pack-core.js2
-rw-r--r--modules/ext/webdav.js210
6 files changed, 1427 insertions, 11 deletions
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';