commit a8835e2210d48a6c03aa4b52ce734fbb650444d1
parent ac4057cd7bb1f817700e679cb14685011bb1a1f4
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 23:27:08 +0000
feat(web): make the about shell feel real: writable fs, pipes, fetch, true cursor
Diffstat:
6 files changed, 2136 insertions(+), 193 deletions(-)
diff --git a/internal/web/scripts/modules/shell-cmds.js b/internal/web/scripts/modules/shell-cmds.js
@@ -0,0 +1,1074 @@
+// Commands for the web shell. Each takes a ctx and returns an exit status:
+// ctx.argv argv, argv[0] is the command name
+// ctx.stdin piped / redirected input, or null
+// ctx.out(s) stdout text, ctx.err(s) stderr text
+// ctx.tty stdout is the terminal; ctx.line(nodes) writes a styled line
+// ctx.sh shell state (fs, user, home, cwd, env, history, ...)
+// ctx.sleep(ms) resolves false if the command was interrupted (^C)
+
+import { basename, dirname, displayPath, inside, nodeSize, sortedChildren } from "./shell-fs.js";
+import { globRegex } from "./shell-lang.js";
+
+export const USAGE = {
+ cat: "cat [-n] [file...] print files (or stdin)",
+ cd: "cd [dir|-] change directory (~ is home, - is the last one)",
+ clear: "clear clear the screen",
+ cp: "cp [-r] src... dest copy files and directories",
+ date: "date print the date and time",
+ echo: "echo [-n] [-e] [text...] print text",
+ env: "env print environment variables",
+ exit: "exit close the session",
+ export: "export [NAME=value...] set environment variables",
+ false: "false do nothing, unsuccessfully",
+ fetch: "fetch system info, the same card as the top of the page",
+ file: "file path... guess what a file is",
+ find: "find [path] [-name pat] [-iname pat] [-type f|d]",
+ grep: "grep [-invcrlwo] pattern [file...] search for a regex",
+ head: "head [-n N] [file...] first N lines (default 10)",
+ help: "help list commands",
+ history: "history [-c] command history (-c clears it)",
+ hostname: "hostname print the host name",
+ id: "id print user and group ids",
+ ls: "ls [-adlR1] [path...] list directory contents",
+ man: "man command show usage for a command",
+ mkdir: "mkdir [-p] dir... make directories",
+ mv: "mv src... dest move or rename",
+ ping: "ping [-c N] host ping a host (^C to stop)",
+ pwd: "pwd print working directory",
+ restore: "restore drop your changes, back to the original files",
+ rm: "rm [-rf] path... remove files and directories",
+ rmdir: "rmdir dir... remove empty directories",
+ seq: "seq [first [step]] last print a sequence of numbers",
+ sleep: "sleep seconds wait (^C to stop)",
+ sort: "sort [-rnu] [file...] sort lines",
+ stat: "stat path... file status",
+ tail: "tail [-n N] [file...] last N lines (default 10)",
+ tee: "tee [-a] file... copy stdin to files and stdout",
+ touch: "touch file... create files or bump their mtime",
+ tree: "tree [-a] [path] draw a directory tree",
+ true: "true do nothing, successfully",
+ type: "type name... say what a command is",
+ uname: "uname [-a] print system information",
+ uniq: "uniq [-c] [file...] collapse repeated lines",
+ unset: "unset NAME... remove environment variables",
+ uptime: "uptime how long this session has been up",
+ wc: "wc [-lwc] [file...] count lines, words and bytes",
+ which: "which command... locate a command",
+ whoami: "whoami print the user name",
+};
+
+export const HELP_TEXT = `prime8 shell. what you create here is saved in this browser.
+
+ navigate ls cd pwd tree find stat file
+ read cat head tail wc grep sort uniq
+ write echo touch mkdir cp mv rm rmdir tee
+ system fetch whoami id hostname uname date uptime env export ping sleep seq
+ session clear history man restore exit
+
+pipes |, redirects > >> < 2>, ; && ||, globs * ? [ ], $VARS and ~ all work.
+man <cmd> for usage. try: cat about, ls projects, echo hi > notes && cat notes`;
+
+const BUILTINS = new Set(["cd", "echo", "exit", "export", "history", "pwd", "type", "unset", "help", "true", "false", "logout"]);
+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+// --- helpers ---
+
+// flags splits leading -abc style options from operands. Options in withValue
+// take the next argument (or the rest of the cluster) as their value.
+function flags(args, withValue = "") {
+ const set = {};
+ const rest = [];
+ let i = 0;
+ for (; i < args.length; i++) {
+ const a = args[i];
+ if (a === "--") {
+ i++;
+ break;
+ }
+ if (!a.startsWith("-") || a === "-" || /^-\d/.test(a)) {
+ rest.push(a);
+ continue;
+ }
+ for (let j = 1; j < a.length; j++) {
+ const f = a[j];
+ if (withValue.includes(f)) {
+ set[f] = a.slice(j + 1) || args[++i];
+ break;
+ }
+ set[f] = true;
+ }
+ }
+ for (; i < args.length; i++) rest.push(args[i]);
+ return { set, rest };
+}
+
+function lines(text) {
+ if (text === "") return [];
+ const ls = text.split("\n");
+ if (ls[ls.length - 1] === "") ls.pop();
+ return ls;
+}
+
+function joinLines(ls) {
+ return ls.length ? ls.join("\n") + "\n" : "";
+}
+
+function pad2(n) {
+ return String(n).padStart(2, "0");
+}
+
+function lsDate(ms) {
+ const d = new Date(ms);
+ const old = Date.now() - ms > 182 * 86400e3;
+ const tail = old ? String(d.getFullYear()).padStart(5) : `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+ return `${MONTHS[d.getMonth()]} ${String(d.getDate()).padStart(2)} ${tail}`;
+}
+
+function longDate(d = new Date()) {
+ const tz = new Intl.DateTimeFormat("en-US", { timeZoneName: "short" }).formatToParts(d).find((p) => p.type === "timeZoneName")?.value || "";
+ return `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${String(d.getDate()).padStart(2)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} ${tz} ${d.getFullYear()}`;
+}
+
+function perms(sh, abs, n) {
+ const w = sh.fs.writable(abs) ? "w" : "-";
+ if (n.dir) return abs === "/tmp" ? "drwxrwxrwt" : `dr${w}xr-xr-x`;
+ return `-r${w}-r--r--`;
+}
+
+// readInputs yields [name, text] for each file operand, or stdin with none.
+function readInputs(ctx, files) {
+ const out = [];
+ if (files.length === 0) {
+ out.push(["", ctx.stdin ?? ""]);
+ return out;
+ }
+ for (const f of files) {
+ if (f === "-") {
+ out.push(["-", ctx.stdin ?? ""]);
+ continue;
+ }
+ const n = ctx.sh.fs.get(ctx.sh.resolve(f));
+ if (!n) ctx.err(`${ctx.argv[0]}: ${f}: No such file or directory\n`);
+ else if (n.dir) ctx.err(`${ctx.argv[0]}: ${f}: Is a directory\n`);
+ else out.push([f, n.content]);
+ }
+ return out;
+}
+
+function nameNode(ctx, n, label = n.name) {
+ return n.dir ? ctx.span("shell-dir", label) : document.createTextNode(label);
+}
+
+// --- commands ---
+
+const commands = {};
+
+commands.help = (ctx) => {
+ ctx.out(HELP_TEXT + "\n");
+ return 0;
+};
+
+commands.man = (ctx) => {
+ const name = ctx.argv[1];
+ if (!name) {
+ ctx.err("What manual page do you want?\nFor example, try 'man man'.\n");
+ return 1;
+ }
+ if (name === "man") {
+ ctx.out(USAGE.man + "\n");
+ return 0;
+ }
+ const u = USAGE[name];
+ if (!u) {
+ ctx.err(`No manual entry for ${name}\n`);
+ return 16;
+ }
+ ctx.out(`${name.toUpperCase()}(1)\n\n ${u}\n`);
+ return 0;
+};
+
+commands.pwd = (ctx) => {
+ ctx.out(ctx.sh.cwd + "\n");
+ return 0;
+};
+
+commands.whoami = (ctx) => {
+ ctx.out(ctx.sh.user + "\n");
+ return 0;
+};
+
+commands.id = (ctx) => {
+ const u = ctx.sh.user;
+ ctx.out(`uid=1000(${u}) gid=1000(${u}) groups=1000(${u})\n`);
+ return 0;
+};
+
+commands.hostname = (ctx) => {
+ ctx.out("prime8\n");
+ return 0;
+};
+
+commands.uname = (ctx) => {
+ const { set } = flags(ctx.argv.slice(1));
+ ctx.out(set.a ? "Linux prime8 6.12.0-prime8 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux\n" : "Linux\n");
+ return 0;
+};
+
+// fetch reprints the fetch card from the top of the about page.
+commands.fetch = async (ctx) => {
+ const card = document.querySelector("#about .fetch");
+ if (!card) {
+ ctx.err("fetch: no system info\n");
+ return 1;
+ }
+ await ctx.sleep(60 + Math.random() * 80); // probing hardware...
+ if (ctx.tty) {
+ const copy = card.cloneNode(true);
+ copy.classList.add("shell-fetch");
+ copy.querySelector("img")?.removeAttribute("loading");
+ ctx.line([copy]);
+ } else {
+ const rows = [...card.querySelectorAll(".fetch-title, .fetch-rule, .fetch-row")];
+ ctx.out(rows.map((r) => r.textContent.trim()).join("\n") + "\n");
+ }
+ return 0;
+};
+
+commands.date = (ctx) => {
+ ctx.out(longDate() + "\n");
+ return 0;
+};
+
+commands.uptime = (ctx) => {
+ const d = new Date();
+ const mins = Math.floor((Date.now() - ctx.sh.bootTime) / 60000);
+ const up = mins < 60 ? `${mins} min` : `${Math.floor(mins / 60)}:${pad2(mins % 60)}`;
+ const load = () => (Math.random() * 0.2).toFixed(2);
+ ctx.out(` ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} up ${up}, 1 user, load average: ${load()}, ${load()}, ${load()}\n`);
+ return 0;
+};
+
+commands.true = () => 0;
+commands.false = () => 1;
+
+commands.echo = (ctx) => {
+ let args = ctx.argv.slice(1);
+ let newline = true;
+ let escapes = false;
+ while (args.length && /^-[neE]+$/.test(args[0])) {
+ if (args[0].includes("n")) newline = false;
+ if (args[0].includes("e")) escapes = true;
+ args = args.slice(1);
+ }
+ let s = args.join(" ");
+ if (escapes) s = s.replace(/\\([ntr\\])/g, (_, c) => ({ n: "\n", t: "\t", r: "\r", "\\": "\\" })[c]);
+ ctx.out(s + (newline ? "\n" : ""));
+ return 0;
+};
+
+commands.env = (ctx) => {
+ const env = ctx.sh.env;
+ ctx.out(joinLines(Object.keys(env).filter((k) => /^[A-Za-z_]/.test(k)).sort().map((k) => `${k}=${env[k]}`)));
+ return 0;
+};
+
+commands.export = (ctx) => {
+ const args = ctx.argv.slice(1).filter((a) => a !== "-p");
+ if (args.length === 0) {
+ const env = ctx.sh.env;
+ ctx.out(joinLines(Object.keys(env).filter((k) => /^[A-Za-z_]/.test(k)).sort().map((k) => `declare -x ${k}="${env[k]}"`)));
+ return 0;
+ }
+ let status = 0;
+ for (const a of args) {
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)(?:=(.*))?$/s.exec(a);
+ if (!m) {
+ ctx.err(`bash: export: \`${a}': not a valid identifier\n`);
+ status = 1;
+ } else if (m[2] !== undefined) ctx.sh.setVar(m[1], m[2]);
+ }
+ return status;
+};
+
+commands.unset = (ctx) => {
+ for (const a of ctx.argv.slice(1)) ctx.sh.unsetVar(a);
+ return 0;
+};
+
+commands.history = (ctx) => {
+ const { set } = flags(ctx.argv.slice(1));
+ if (set.c) {
+ ctx.sh.clearHistory();
+ return 0;
+ }
+ const h = ctx.sh.history;
+ const width = String(h.length).length;
+ ctx.out(joinLines(h.map((l, i) => `${String(i + 1).padStart(Math.max(width, 4))} ${l}`)));
+ return 0;
+};
+
+commands.cd = (ctx) => {
+ const sh = ctx.sh;
+ const args = ctx.argv.slice(1);
+ if (args.length > 1) {
+ ctx.err("bash: cd: too many arguments\n");
+ return 1;
+ }
+ let target = args[0] ?? "";
+ if (target === "-") {
+ if (!sh.oldpwd) {
+ ctx.err("bash: cd: OLDPWD not set\n");
+ return 1;
+ }
+ target = sh.oldpwd;
+ ctx.out(target + "\n");
+ }
+ const abs = sh.resolve(target);
+ const n = sh.fs.get(abs);
+ if (!n) {
+ ctx.err(`bash: cd: ${target}: No such file or directory\n`);
+ return 1;
+ }
+ if (!n.dir) {
+ ctx.err(`bash: cd: ${target}: Not a directory\n`);
+ return 1;
+ }
+ sh.chdir(abs);
+ return 0;
+};
+
+commands.ls = async (ctx) => {
+ const sh = ctx.sh;
+ const { set, rest } = flags(ctx.argv.slice(1));
+ const all = set.a || set.A;
+ const long = set.l;
+ const perLine = set["1"] || !ctx.tty;
+ const recursive = set.R;
+ const paths = rest.length ? rest : ["."];
+ let status = 0;
+
+ const entriesOf = (abs, n) => {
+ const kids = sortedChildren(n).filter((c) => all || !c.name.startsWith("."));
+ const list = kids.map((c) => ({ label: c.name, node: c, abs: abs === "/" ? "/" + c.name : abs + "/" + c.name }));
+ if (set.a) {
+ list.unshift({ label: "..", node: sh.fs.get(dirname(abs)), abs: dirname(abs) }, { label: ".", node: n, abs });
+ list.sort((a, b) => (a.label < b.label ? -1 : 1));
+ }
+ return list;
+ };
+
+ const show = (list) => {
+ if (long) {
+ const sizes = list.map((e) => String(nodeSize(e.node)));
+ const width = Math.max(1, ...sizes.map((s) => s.length));
+ const owners = list.map((e) => sh.fs.owner(e.abs));
+ const ow = Math.max(1, ...owners.map((s) => s.length));
+ list.forEach((e, i) => {
+ const links = e.node.dir ? 2 + Object.values(e.node.children).filter((c) => c.dir).length : 1;
+ const head = `${perms(sh, e.abs, e.node)} ${String(links).padStart(2)} ${owners[i].padEnd(ow)} ${owners[i].padEnd(ow)} ${sizes[i].padStart(width)} ${lsDate(e.node.mtime)} `;
+ if (ctx.tty) ctx.line([document.createTextNode(head), nameNode(ctx, e.node, e.label)]);
+ else ctx.out(head + e.label + "\n");
+ });
+ return;
+ }
+ if (perLine) {
+ for (const e of list) {
+ if (ctx.tty) ctx.line([nameNode(ctx, e.node, e.label)]);
+ else ctx.out(e.label + "\n");
+ }
+ return;
+ }
+ if (list.length === 0) return;
+ const nodes = [];
+ list.forEach((e, i) => {
+ if (i) nodes.push(document.createTextNode(" "));
+ nodes.push(nameNode(ctx, e.node, e.label));
+ });
+ ctx.line(nodes);
+ };
+
+ const files = [];
+ const dirs = [];
+ for (const p of paths) {
+ const abs = sh.resolve(p);
+ const n = sh.fs.get(abs);
+ if (!n) {
+ ctx.err(`ls: cannot access '${p}': No such file or directory\n`);
+ status = 2;
+ } else if (n.dir && !set.d) dirs.push({ p, abs, n });
+ else files.push({ label: p, node: n, abs });
+ }
+ if (files.length) show(files);
+ const headers = dirs.length + files.length > 1 || recursive;
+ let first = files.length === 0;
+ const walk = async (p, abs, n) => {
+ if (!first) ctx.out("\n");
+ first = false;
+ if (headers) ctx.out(`${p}:\n`);
+ const list = entriesOf(abs, n);
+ if (long) ctx.out(`total ${list.reduce((t, e) => t + Math.ceil(nodeSize(e.node) / 4096) * 4, 0)}\n`);
+ show(list);
+ if (!recursive) return;
+ for (const e of list) {
+ if (!e.node.dir || e.label === "." || e.label === "..") continue;
+ if (!(await ctx.sleep(4))) return;
+ await walk(p === "/" ? "/" + e.label : `${p}/${e.label}`, e.abs, e.node);
+ }
+ };
+ for (const d of dirs) await walk(d.p, d.abs, d.n);
+ return status;
+};
+
+commands.cat = (ctx) => {
+ const { set, rest } = flags(ctx.argv.slice(1));
+ const inputs = readInputs(ctx, rest);
+ let text = inputs.map(([, t]) => t).join("");
+ if (set.n) text = joinLines(lines(text).map((l, i) => `${String(i + 1).padStart(6)}\t${l}`));
+ ctx.out(text);
+ return inputs.length === Math.max(rest.length, 1) ? 0 : 1;
+};
+
+function headTail(ctx, tail) {
+ const args = ctx.argv.slice(1).map((a) => (/^-\d+$/.test(a) ? "-n" + a.slice(1) : a));
+ const { set, rest } = flags(args, "n");
+ const n = set.n === undefined ? 10 : parseInt(set.n, 10);
+ if (Number.isNaN(n)) {
+ ctx.err(`${ctx.argv[0]}: invalid number of lines: '${set.n}'\n`);
+ return 1;
+ }
+ const inputs = readInputs(ctx, rest);
+ inputs.forEach(([name, text], i) => {
+ if (inputs.length > 1) ctx.out(`${i ? "\n" : ""}==> ${name} <==\n`);
+ const ls = lines(text);
+ ctx.out(joinLines(tail ? ls.slice(Math.max(ls.length - n, 0)) : ls.slice(0, n)));
+ });
+ return inputs.length === Math.max(rest.length, 1) ? 0 : 1;
+}
+
+commands.head = (ctx) => headTail(ctx, false);
+commands.tail = (ctx) => headTail(ctx, true);
+
+commands.wc = (ctx) => {
+ const { set, rest } = flags(ctx.argv.slice(1));
+ const pick = set.l || set.w || set.c ? set : { l: true, w: true, c: true };
+ const inputs = readInputs(ctx, rest);
+ const rows = inputs.map(([name, t]) => [(t.match(/\n/g) || []).length, (t.match(/\S+/g) || []).length, new TextEncoder().encode(t).length, name]);
+ if (rows.length > 1) rows.push([rows.reduce((s, r) => s + r[0], 0), rows.reduce((s, r) => s + r[1], 0), rows.reduce((s, r) => s + r[2], 0), "total"]);
+ const cols = (pick.l ? 1 : 0) + (pick.w ? 1 : 0) + (pick.c ? 1 : 0);
+ const width = Math.max(...rows.flatMap((r) => [pick.l && r[0], pick.w && r[1], pick.c && r[2]].filter((x) => x !== false && x !== undefined).map((x) => String(x).length)), rest.length || cols === 1 ? 1 : 7);
+ for (const r of rows) {
+ const cols = [];
+ if (pick.l) cols.push(String(r[0]).padStart(width));
+ if (pick.w) cols.push(String(r[1]).padStart(width));
+ if (pick.c) cols.push(String(r[2]).padStart(width));
+ ctx.out(cols.join(" ") + (r[3] && r[3] !== "-" ? " " + r[3] : "") + "\n");
+ }
+ return inputs.length === Math.max(rest.length, 1) ? 0 : 1;
+};
+
+commands.grep = async (ctx) => {
+ const sh = ctx.sh;
+ const { set, rest } = flags(ctx.argv.slice(1), "e");
+ const pattern = set.e ?? rest.shift();
+ if (pattern === undefined) {
+ ctx.err("Usage: grep [OPTION]... PATTERNS [FILE]...\nTry 'grep --help' for more information.\n");
+ return 2;
+ }
+ let src = pattern;
+ if (set.F) src = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ if (set.w) src = `\\b(?:${src})\\b`;
+ let re;
+ try {
+ re = new RegExp(src, set.i ? "gi" : "g");
+ } catch {
+ re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), set.i ? "gi" : "g");
+ }
+ const recursive = set.r || set.R;
+ const inputs = [];
+ let status = 1;
+ let errors = false;
+ if (rest.length === 0) {
+ if (recursive) rest.push(".");
+ else inputs.push(["", ctx.stdin ?? ""]);
+ }
+ const collect = (label, abs, n) => {
+ if (!n.dir) {
+ inputs.push([label, n.content]);
+ return;
+ }
+ for (const c of sortedChildren(n)) collect(label === "." && !recursive ? c.name : `${label}/${c.name}`, `${abs === "/" ? "" : abs}/${c.name}`, c);
+ };
+ for (const f of rest) {
+ const abs = sh.resolve(f);
+ const n = sh.fs.get(abs);
+ if (!n) {
+ if (!set.s) ctx.err(`grep: ${f}: No such file or directory\n`);
+ errors = true;
+ } else if (n.dir && !recursive) {
+ ctx.err(`grep: ${f}: Is a directory\n`);
+ errors = true;
+ } else collect(f.replace(/\/+$/, "") || f, abs, n);
+ }
+ if (recursive) await ctx.sleep(Math.min(300, inputs.length * 3));
+ const prefix = inputs.length > 1 || recursive;
+ for (const [name, text] of inputs) {
+ let count = 0;
+ const ls = lines(text);
+ for (let i = 0; i < ls.length; i++) {
+ re.lastIndex = 0;
+ const hit = re.test(ls[i]);
+ if (hit === !!set.v) continue;
+ count++;
+ status = 0;
+ if (set.l || set.c || set.q) continue;
+ const head = `${prefix ? name + ":" : ""}${set.n ? i + 1 + ":" : ""}`;
+ if (set.o && !set.v) {
+ re.lastIndex = 0;
+ for (const m of ls[i].matchAll(re)) {
+ if (m[0] === "") continue;
+ if (ctx.tty) ctx.line([...(head ? [ctx.span("shell-grep-file", head)] : []), ctx.span("shell-match", m[0])]);
+ else ctx.out(head + m[0] + "\n");
+ }
+ continue;
+ }
+ if (!ctx.tty || set.v) {
+ if (ctx.tty) ctx.line([ctx.span("shell-grep-file", head), document.createTextNode(ls[i])]);
+ else ctx.out(head + ls[i] + "\n");
+ continue;
+ }
+ const nodes = head ? [ctx.span("shell-grep-file", head)] : [];
+ let last = 0;
+ re.lastIndex = 0;
+ for (const m of ls[i].matchAll(re)) {
+ if (m[0] === "") break;
+ nodes.push(document.createTextNode(ls[i].slice(last, m.index)), ctx.span("shell-match", m[0]));
+ last = m.index + m[0].length;
+ }
+ nodes.push(document.createTextNode(ls[i].slice(last)));
+ ctx.line(nodes);
+ }
+ if (set.c) ctx.out(`${prefix ? name + ":" : ""}${count}\n`);
+ else if (set.l && count) ctx.out(name + "\n");
+ }
+ return errors && status !== 0 ? 2 : status;
+};
+
+commands.sort = (ctx) => {
+ const { set, rest } = flags(ctx.argv.slice(1));
+ let ls = readInputs(ctx, rest).flatMap(([, t]) => lines(t));
+ if (set.n) ls.sort((a, b) => (parseFloat(a) || 0) - (parseFloat(b) || 0));
+ else ls.sort((a, b) => (set.f ? a.toLowerCase().localeCompare(b.toLowerCase()) : a < b ? -1 : a > b ? 1 : 0));
+ if (set.r) ls.reverse();
+ if (set.u) ls = ls.filter((l, i) => i === 0 || l !== ls[i - 1]);
+ ctx.out(joinLines(ls));
+ return 0;
+};
+
+commands.uniq = (ctx) => {
+ const { set, rest } = flags(ctx.argv.slice(1));
+ const ls = readInputs(ctx, rest.slice(0, 1)).flatMap(([, t]) => lines(t));
+ const out = [];
+ for (const l of ls) {
+ const prev = out[out.length - 1];
+ if (prev && prev.l === l) prev.n++;
+ else out.push({ l, n: 1 });
+ }
+ const rows = out.filter((r) => (set.d ? r.n > 1 : set.u ? r.n === 1 : true));
+ ctx.out(joinLines(rows.map((r) => (set.c ? `${String(r.n).padStart(7)} ${r.l}` : r.l))));
+ return 0;
+};
+
+commands.seq = (ctx) => {
+ const nums = ctx.argv.slice(1).map(Number);
+ if (nums.length === 0 || nums.length > 3 || nums.some(Number.isNaN)) {
+ ctx.err(nums.length === 0 ? "seq: missing operand\n" : `seq: invalid argument\n`);
+ return 1;
+ }
+ const [first, step, last] = nums.length === 1 ? [1, 1, nums[0]] : nums.length === 2 ? [nums[0], 1, nums[1]] : nums;
+ if (step === 0) {
+ ctx.err("seq: invalid Zero increment value: '0'\n");
+ return 1;
+ }
+ const out = [];
+ for (let x = first; step > 0 ? x <= last : x >= last; x += step) {
+ out.push(String(Math.round(x * 1e9) / 1e9));
+ if (out.length >= 100000) break;
+ }
+ ctx.out(joinLines(out));
+ return 0;
+};
+
+// writeOut is how commands create or replace files; it reports errors as
+// "<cmd>: <how>: <path>: <reason>" style through err and returns false.
+export function writeFile(sh, path, content, append) {
+ const abs = sh.resolve(path);
+ if (abs === "/dev/null") return "";
+ const n = sh.fs.get(abs);
+ if (n && n.dir) return "Is a directory";
+ const why = sh.fs.checkCreate(abs);
+ if (why) return why;
+ sh.fs.write(abs, append && n ? n.content + content : content);
+ return "";
+}
+
+commands.tee = (ctx) => {
+ const { set, rest } = flags(ctx.argv.slice(1));
+ const text = ctx.stdin ?? "";
+ let status = 0;
+ for (const f of rest) {
+ const why = writeFile(ctx.sh, f, text, set.a);
+ if (why) {
+ ctx.err(`tee: ${f}: ${why}\n`);
+ status = 1;
+ }
+ }
+ ctx.out(text);
+ return status;
+};
+
+commands.touch = async (ctx) => {
+ const sh = ctx.sh;
+ const { rest } = flags(ctx.argv.slice(1));
+ if (rest.length === 0) {
+ ctx.err("touch: missing file operand\n");
+ return 1;
+ }
+ let status = 0;
+ for (const f of rest) {
+ const abs = sh.resolve(f);
+ const n = sh.fs.get(abs);
+ if (n) {
+ if (!sh.fs.writable(abs)) {
+ ctx.err(`touch: cannot touch '${f}': Permission denied\n`);
+ status = 1;
+ } else sh.fs.touch(abs);
+ continue;
+ }
+ const why = sh.fs.checkCreate(abs);
+ if (why) {
+ ctx.err(`touch: cannot touch '${f}': ${why}\n`);
+ status = 1;
+ } else sh.fs.write(abs, "");
+ }
+ await ctx.sleep(6 + Math.random() * 10);
+ return status;
+};
+
+commands.mkdir = async (ctx) => {
+ const sh = ctx.sh;
+ const { set, rest } = flags(ctx.argv.slice(1));
+ if (rest.length === 0) {
+ ctx.err("mkdir: missing operand\n");
+ return 1;
+ }
+ let status = 0;
+ for (const d of rest) {
+ const abs = sh.resolve(d);
+ if (set.p) {
+ let cur = "";
+ for (const part of abs.split("/").filter(Boolean)) {
+ cur += "/" + part;
+ const n = sh.fs.get(cur);
+ if (n && n.dir) continue;
+ const why = n ? "File exists" : sh.fs.checkCreate(cur);
+ if (why) {
+ ctx.err(`mkdir: cannot create directory '${d}': ${why}\n`);
+ status = 1;
+ break;
+ }
+ sh.fs.mkdir(cur);
+ }
+ continue;
+ }
+ const why = sh.fs.get(abs) ? "File exists" : sh.fs.checkCreate(abs);
+ if (why) {
+ ctx.err(`mkdir: cannot create directory '${d}': ${why}\n`);
+ status = 1;
+ } else sh.fs.mkdir(abs);
+ }
+ await ctx.sleep(6 + Math.random() * 10);
+ return status;
+};
+
+commands.rmdir = (ctx) => {
+ const sh = ctx.sh;
+ const { rest } = flags(ctx.argv.slice(1));
+ if (rest.length === 0) {
+ ctx.err("rmdir: missing operand\n");
+ return 1;
+ }
+ let status = 0;
+ for (const d of rest) {
+ const abs = sh.resolve(d);
+ const n = sh.fs.get(abs);
+ let why = "";
+ if (!n) why = "No such file or directory";
+ else if (!n.dir) why = "Not a directory";
+ else if (Object.keys(n.children).length) why = "Directory not empty";
+ else why = sh.fs.checkCreate(abs);
+ if (why) {
+ ctx.err(`rmdir: failed to remove '${d}': ${why}\n`);
+ status = 1;
+ } else sh.fs.remove(abs);
+ }
+ return status;
+};
+
+commands.rm = async (ctx) => {
+ const sh = ctx.sh;
+ const { set, rest } = flags(ctx.argv.slice(1));
+ const recursive = set.r || set.R;
+ if (rest.length === 0) {
+ if (set.f) return 0;
+ ctx.err("rm: missing operand\n");
+ return 1;
+ }
+ let status = 0;
+ let removed = 0;
+ for (const p of rest) {
+ const abs = sh.resolve(p);
+ if (/(^|\/)\.\.?\/*$/.test(p)) {
+ ctx.err(`rm: refusing to remove '.' or '..' directory: skipping '${p}'\n`);
+ status = 1;
+ continue;
+ }
+ if (abs === "/" && recursive) {
+ ctx.err("rm: it is dangerous to operate recursively on '/'\nrm: use --no-preserve-root to override this failsafe\n");
+ status = 1;
+ continue;
+ }
+ const n = sh.fs.get(abs);
+ if (!n) {
+ if (!set.f) {
+ ctx.err(`rm: cannot remove '${p}': No such file or directory\n`);
+ status = 1;
+ }
+ continue;
+ }
+ if (n.dir && !recursive) {
+ ctx.err(`rm: cannot remove '${p}': Is a directory\n`);
+ status = 1;
+ continue;
+ }
+ const why = sh.fs.checkCreate(abs);
+ if (why) {
+ ctx.err(`rm: cannot remove '${p}': ${why}\n`);
+ status = 1;
+ continue;
+ }
+ removed += sh.fs.count(n);
+ sh.fs.remove(abs);
+ }
+ await ctx.sleep(Math.min(250, 4 + removed * 2));
+ return status;
+};
+
+function transfer(ctx, move) {
+ const sh = ctx.sh;
+ const name = ctx.argv[0];
+ const { set, rest } = flags(ctx.argv.slice(1));
+ const recursive = set.r || set.R || set.a;
+ if (rest.length < 2) {
+ ctx.err(rest.length ? `${name}: missing destination file operand after '${rest[0]}'\n` : `${name}: missing file operand\n`);
+ return 1;
+ }
+ const destArg = rest.pop();
+ const destAbs = sh.resolve(destArg);
+ const destNode = sh.fs.get(destAbs);
+ if (rest.length > 1 && !(destNode && destNode.dir)) {
+ ctx.err(`${name}: target '${destArg}': Not a directory\n`);
+ return 1;
+ }
+ let status = 0;
+ for (const src of rest) {
+ const srcAbs = sh.resolve(src);
+ const node = sh.fs.get(srcAbs);
+ if (!node) {
+ ctx.err(`${name}: cannot stat '${src}': No such file or directory\n`);
+ status = 1;
+ continue;
+ }
+ if (node.dir && !move && !recursive) {
+ ctx.err(`cp: -r not specified; omitting directory '${src}'\n`);
+ status = 1;
+ continue;
+ }
+ const target = destNode && destNode.dir ? (destAbs === "/" ? "" : destAbs) + "/" + basename(srcAbs) : destAbs;
+ if (target === srcAbs) {
+ ctx.err(`${name}: '${src}' and '${destArg}' are the same file\n`);
+ status = 1;
+ continue;
+ }
+ if (node.dir && inside(target, srcAbs)) {
+ ctx.err(`${name}: cannot ${move ? "move" : "copy"} '${src}' to a subdirectory of itself, '${displayPath(sh.home, target)}'\n`);
+ status = 1;
+ continue;
+ }
+ const existing = sh.fs.get(target);
+ if (existing && existing.dir && !node.dir) {
+ ctx.err(`${name}: cannot overwrite directory '${displayPath(sh.home, target)}' with non-directory\n`);
+ status = 1;
+ continue;
+ }
+ if (existing && !existing.dir && node.dir) {
+ ctx.err(`${name}: cannot overwrite non-directory '${displayPath(sh.home, target)}' with directory '${src}'\n`);
+ status = 1;
+ continue;
+ }
+ const why = sh.fs.checkCreate(target) || (move ? sh.fs.checkCreate(srcAbs) : "");
+ if (why) {
+ ctx.err(`${name}: cannot ${move ? "move" : "create"} '${src}': ${why}\n`);
+ status = 1;
+ continue;
+ }
+ sh.fs.copy(node, target);
+ if (move) sh.fs.remove(srcAbs);
+ }
+ return status;
+}
+
+commands.cp = async (ctx) => {
+ const status = transfer(ctx, false);
+ await ctx.sleep(8 + Math.random() * 20);
+ return status;
+};
+
+commands.mv = async (ctx) => {
+ const status = transfer(ctx, true);
+ await ctx.sleep(5 + Math.random() * 10);
+ return status;
+};
+
+commands.find = async (ctx) => {
+ const sh = ctx.sh;
+ const args = ctx.argv.slice(1);
+ const roots = [];
+ while (args.length && !args[0].startsWith("-")) roots.push(args.shift());
+ if (roots.length === 0) roots.push(".");
+ let nameRe = null;
+ let type = "";
+ let maxDepth = Infinity;
+ while (args.length) {
+ const a = args.shift();
+ const v = args.shift();
+ if (v === undefined) {
+ ctx.err(`find: missing argument to \`${a}'\n`);
+ return 1;
+ }
+ if (a === "-name" || a === "-iname") nameRe = globRegex(v, a === "-iname" ? "i" : "");
+ else if (a === "-type") type = v;
+ else if (a === "-maxdepth") maxDepth = parseInt(v, 10);
+ else {
+ ctx.err(`find: unknown predicate \`${a}'\n`);
+ return 1;
+ }
+ }
+ let status = 0;
+ let visited = 0;
+ const out = [];
+ const walk = (label, n, d) => {
+ visited++;
+ const ok = (!nameRe || nameRe.test(n.name || "/")) && (!type || (type === "d" ? n.dir : type === "f" ? !n.dir : false));
+ if (ok) out.push([label, n]);
+ if (n.dir && d < maxDepth) for (const c of sortedChildren(n)) walk(label.endsWith("/") ? label + c.name : `${label}/${c.name}`, c, d + 1);
+ };
+ for (const r of roots) {
+ const n = sh.fs.get(sh.resolve(r));
+ if (!n) {
+ ctx.err(`find: '${r}': No such file or directory\n`);
+ status = 1;
+ continue;
+ }
+ walk(r, n, 0);
+ }
+ await ctx.sleep(Math.min(400, 10 + visited * 2));
+ for (const [label, n] of out) {
+ if (ctx.tty) ctx.line([nameNode(ctx, n, label)]);
+ else ctx.out(label + "\n");
+ }
+ return status;
+};
+
+commands.tree = async (ctx) => {
+ const sh = ctx.sh;
+ const { set, rest } = flags(ctx.argv.slice(1));
+ const p = rest[0] ?? ".";
+ const root = sh.fs.get(sh.resolve(p));
+ if (!root) {
+ ctx.err(`${p} [error opening dir]\n\n0 directories, 0 files\n`);
+ return 2;
+ }
+ let dirs = 0;
+ let files = 0;
+ const emit = (prefix, label, n) => {
+ if (ctx.tty) ctx.line([document.createTextNode(prefix), nameNode(ctx, n, label)]);
+ else ctx.out(prefix + label + "\n");
+ };
+ emit("", p, root);
+ const walk = (n, prefix) => {
+ const kids = sortedChildren(n).filter((c) => set.a || !c.name.startsWith("."));
+ kids.forEach((c, i) => {
+ const last = i === kids.length - 1;
+ emit(prefix + (last ? "└── " : "├── "), c.name, c);
+ if (c.dir) {
+ dirs++;
+ walk(c, prefix + (last ? " " : "│ "));
+ } else files++;
+ });
+ };
+ if (root.dir) walk(root, "");
+ await ctx.sleep(Math.min(300, (dirs + files) * 2));
+ ctx.out(`\n${dirs} director${dirs === 1 ? "y" : "ies"}, ${files} file${files === 1 ? "" : "s"}\n`);
+ return 0;
+};
+
+commands.stat = (ctx) => {
+ const sh = ctx.sh;
+ const args = ctx.argv.slice(1);
+ if (args.length === 0) {
+ ctx.err("stat: missing operand\n");
+ return 1;
+ }
+ let status = 0;
+ for (const p of args) {
+ const abs = sh.resolve(p);
+ const n = sh.fs.get(abs);
+ if (!n) {
+ ctx.err(`stat: cannot statx '${p}': No such file or directory\n`);
+ status = 1;
+ continue;
+ }
+ const size = nodeSize(n);
+ const owner = sh.fs.owner(abs);
+ const id = owner === "root" ? 0 : 1000;
+ const when = new Date(n.mtime);
+ const ts = `${when.getFullYear()}-${pad2(when.getMonth() + 1)}-${pad2(when.getDate())} ${pad2(when.getHours())}:${pad2(when.getMinutes())}:${pad2(when.getSeconds())}`;
+ ctx.out(
+ ` File: ${p}\n Size: ${size}\tBlocks: ${Math.ceil(size / 512)}\tIO Block: 4096 ${n.dir ? "directory" : size ? "regular file" : "regular empty file"}\n` +
+ `Access: (${n.dir ? "0755" : "0644"}/${perms(sh, abs, n)}) Uid: (${String(id).padStart(5)}/${owner.padStart(8)}) Gid: (${String(id).padStart(5)}/${owner.padStart(8)})\n` +
+ `Modify: ${ts}\n`,
+ );
+ }
+ return status;
+};
+
+commands.file = (ctx) => {
+ const sh = ctx.sh;
+ let status = 0;
+ for (const p of ctx.argv.slice(1)) {
+ const n = sh.fs.get(sh.resolve(p));
+ let kind;
+ if (!n) {
+ kind = "cannot open (No such file or directory)";
+ status = 1;
+ } else if (n.dir) kind = "directory";
+ else if (n.content === "") kind = "empty";
+ else if (/^#!/.test(n.content)) kind = "script, ASCII text executable";
+ else if (/^\s*[{[]/.test(n.content) && (() => { try { JSON.parse(n.content); return true; } catch { return false; } })()) kind = "JSON text data";
+ else kind = /^[\x00-\x7f]*$/.test(n.content) ? "ASCII text" : "Unicode text, UTF-8 text";
+ ctx.out(`${p}: ${kind}\n`);
+ }
+ return status;
+};
+
+commands.which = (ctx) => {
+ let status = 0;
+ for (const c of ctx.argv.slice(1)) {
+ if (commands[c] && !BUILTINS.has(c)) ctx.out(`/usr/bin/${c}\n`);
+ else status = 1;
+ }
+ return status;
+};
+
+commands.type = (ctx) => {
+ let status = 0;
+ for (const c of ctx.argv.slice(1)) {
+ if (BUILTINS.has(c)) ctx.out(`${c} is a shell builtin\n`);
+ else if (commands[c]) ctx.out(`${c} is /usr/bin/${c}\n`);
+ else {
+ ctx.err(`bash: type: ${c}: not found\n`);
+ status = 1;
+ }
+ }
+ return status;
+};
+
+commands.sleep = async (ctx) => {
+ const arg = ctx.argv[1];
+ const m = arg && /^(\d*\.?\d+)([smh]?)$/.exec(arg);
+ if (!m) {
+ ctx.err(arg ? `sleep: invalid time interval '${arg}'\n` : "sleep: missing operand\n");
+ return 1;
+ }
+ const secs = parseFloat(m[1]) * ({ s: 1, m: 60, h: 3600 }[m[2]] ?? 1);
+ await ctx.sleep(secs * 1000);
+ return 0;
+};
+
+const HOSTS = { "prime8.dev": "5.78.138.218", "www.prime8.dev": "5.78.138.218", "git.prime8.dev": "5.78.138.218", prime8: "127.0.1.1", localhost: "127.0.0.1", "127.0.0.1": "127.0.0.1" };
+
+commands.ping = async (ctx) => {
+ const { set, rest } = flags(ctx.argv.slice(1), "c");
+ const host = rest[0];
+ if (!host) {
+ ctx.err("ping: usage error: Destination address required\n");
+ return 1;
+ }
+ const ip = HOSTS[host];
+ if (!ip) {
+ await ctx.sleep(400 + Math.random() * 600);
+ ctx.err(`ping: ${host}: Temporary failure in name resolution\n`);
+ return 2;
+ }
+ const count = set.c ? parseInt(set.c, 10) : ctx.tty ? 100 : 4;
+ const local = ip.startsWith("127.");
+ const base = local ? 0.04 : 14 + Math.random() * 12;
+ const times = [];
+ const start = Date.now();
+ ctx.out(`PING ${host} (${ip}) 56(84) bytes of data.\n`);
+ for (let seq = 1; seq <= count; seq++) {
+ const t = local ? base + Math.random() * 0.03 : base + Math.random() * 4 + (Math.random() < 0.08 ? Math.random() * 30 : 0);
+ if (!(await ctx.sleep(seq === 1 ? t : 1000))) break;
+ times.push(t);
+ ctx.out(`64 bytes from ${host} (${ip}): icmp_seq=${seq} ttl=${local ? 64 : 54} time=${t.toFixed(local ? 3 : 1)} ms\n`);
+ }
+ const n = times.length;
+ const avg = n ? times.reduce((a, b) => a + b, 0) / n : 0;
+ const mdev = n ? Math.sqrt(times.reduce((a, b) => a + (b - avg) ** 2, 0) / n) : 0;
+ ctx.out(`\n--- ${host} ping statistics ---\n${n} packets transmitted, ${n} received, 0% packet loss, time ${Math.max(0, Date.now() - start - 1)}ms\n`);
+ if (n) ctx.out(`rtt min/avg/max/mdev = ${Math.min(...times).toFixed(3)}/${avg.toFixed(3)}/${Math.max(...times).toFixed(3)}/${mdev.toFixed(3)} ms\n`);
+ return 0;
+};
+
+commands.restore = (ctx) => {
+ const n = ctx.sh.fs.changes();
+ ctx.sh.fs.reset();
+ if (!ctx.sh.fs.get(ctx.sh.cwd)) ctx.sh.chdir(ctx.sh.home);
+ ctx.out(n ? `restored the original files (${n} change${n === 1 ? "" : "s"} dropped)\n` : "nothing to restore\n");
+ return 0;
+};
+
+commands.sudo = (ctx) => {
+ ctx.err(`${ctx.sh.user} is not in the sudoers file. This incident will be reported.\n`);
+ return 1;
+};
+
+commands.su = async (ctx) => {
+ await ctx.sleep(2000 + Math.random() * 800);
+ ctx.err("su: Authentication failure\n");
+ return 1;
+};
+
+for (const ed of ["vi", "vim", "nvim", "nano", "hx", "emacs", "ed"]) {
+ commands[ed] = (ctx) => {
+ ctx.err(`${ed}: no editor in this shell. try: echo "hello" > notes, or echo more >> notes\n`);
+ return 127;
+ };
+}
+
+// exit, logout, clear and reset need the terminal; shell.js handles them.
+export const SPECIAL = new Set(["exit", "logout", "clear", "reset"]);
+
+export { commands, longDate };
+export const COMMAND_NAMES = [...Object.keys(commands).filter((c) => !["vi", "vim", "nvim", "nano", "emacs", "ed", "hx"].includes(c)), ...SPECIAL].sort();
diff --git a/internal/web/scripts/modules/shell-fs.js b/internal/web/scripts/modules/shell-fs.js
@@ -0,0 +1,270 @@
+// Writable filesystem for the web shell. The server's read-only tree
+// (/api/fs, generated from site content) is the base layer; whatever the
+// visitor creates, edits or deletes is kept as an overlay in localStorage and
+// replayed on top of it, so their files survive reloads and new site content
+// still shows up underneath.
+//
+// Overlay entries are keyed by absolute path:
+// null deleted (whiteout over a base node)
+// {d:1, m, f?:1} directory; f = fresh, replaces whatever the base had
+// {c, m} file with content c
+// m is the mtime in ms.
+
+const STORE_KEY = "prime8.fs.v1";
+const QUOTA = 1 << 20; // chars of serialized overlay, well under localStorage limits
+
+export const enc = new TextEncoder();
+
+// --- paths (same rules as internal/vfs) ---
+
+export function cleanPath(p) {
+ const out = [];
+ for (const part of p.split("/")) {
+ if (part === "" || part === ".") continue;
+ if (part === "..") out.pop();
+ else out.push(part);
+ }
+ return "/" + out.join("/");
+}
+
+export function resolvePath(home, cwd, p) {
+ if (p === "" || p === "~") return home;
+ if (p.startsWith("~/")) p = home + p.slice(1);
+ else if (!p.startsWith("/")) p = cwd + "/" + p;
+ return cleanPath(p);
+}
+
+export function displayPath(home, abs) {
+ if (abs === home) return "~";
+ if (abs.startsWith(home + "/")) return "~" + abs.slice(home.length);
+ return abs;
+}
+
+export function dirname(abs) {
+ const i = abs.lastIndexOf("/");
+ return i <= 0 ? "/" : abs.slice(0, i);
+}
+
+export function basename(abs) {
+ return abs.slice(abs.lastIndexOf("/") + 1);
+}
+
+export function inside(abs, base) {
+ return abs === base || abs.startsWith(base === "/" ? "/" : base + "/");
+}
+
+export function lookup(root, abs) {
+ let n = root;
+ for (const part of abs.split("/")) {
+ if (part === "") continue;
+ if (!n.dir) return null;
+ const c = n.children[part];
+ if (!c) return null;
+ n = c;
+ }
+ return n;
+}
+
+export function sortedChildren(n) {
+ return Object.values(n.children || {}).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
+}
+
+export function nodeSize(n) {
+ return n.dir ? 4096 : enc.encode(n.content).length;
+}
+
+function mkDir(name, mtime) {
+ return { name, dir: true, children: {}, mtime };
+}
+
+function mkFile(name, content, mtime) {
+ return { name, dir: false, content, mtime };
+}
+
+function cloneBase(n, mtime) {
+ if (!n.dir) return mkFile(n.name, n.content || "", mtime);
+ const d = mkDir(n.name, mtime);
+ for (const [k, c] of Object.entries(n.children || {})) d.children[k] = cloneBase(c, mtime);
+ return d;
+}
+
+function depth(p) {
+ return p.split("/").length;
+}
+
+function applyOverlay(root, entries) {
+ const keys = Object.keys(entries).sort((a, b) => depth(a) - depth(b) || (a < b ? -1 : 1));
+ for (const k of keys) {
+ const parent = lookup(root, dirname(k));
+ if (!parent || !parent.dir) continue;
+ const name = basename(k);
+ const v = entries[k];
+ if (v === null) delete parent.children[name];
+ else if (v.d) {
+ const cur = parent.children[name];
+ if (v.f || !cur || !cur.dir) parent.children[name] = mkDir(name, v.m);
+ else cur.mtime = v.m;
+ } else parent.children[name] = mkFile(name, v.c, v.m);
+ }
+}
+
+export class VFS {
+ constructor(data) {
+ this.base = data.root;
+ this.user = data.user;
+ this.home = data.home;
+ const t = new Date();
+ t.setHours(0, 0, 0, 0);
+ this.baseTime = t.getTime();
+ this.load();
+ }
+
+ load() {
+ let saved = "";
+ try {
+ saved = window.localStorage.getItem(STORE_KEY) || "";
+ } catch {
+ saved = "";
+ }
+ this.saved = saved;
+ this.entries = {};
+ try {
+ const parsed = saved ? JSON.parse(saved) : null;
+ if (parsed && typeof parsed.e === "object" && parsed.e) this.entries = parsed.e;
+ } catch {
+ this.entries = {};
+ }
+ this.dirty = false;
+ this.rebuild();
+ }
+
+ rebuild() {
+ this.root = cloneBase(this.base, this.baseTime);
+ applyOverlay(this.root, this.entries);
+ }
+
+ changes() {
+ return Object.keys(this.entries).length;
+ }
+
+ // commit persists the overlay. On quota failure it rolls back to the last
+ // saved state and returns false.
+ commit() {
+ if (!this.dirty) return true;
+ this.dirty = false;
+ const s = JSON.stringify({ v: 1, e: this.entries });
+ let ok = s.length <= QUOTA;
+ if (ok) {
+ try {
+ window.localStorage.setItem(STORE_KEY, s);
+ } catch {
+ ok = false;
+ }
+ }
+ if (ok) {
+ this.saved = s;
+ return true;
+ }
+ this.load();
+ return false;
+ }
+
+ reset() {
+ this.entries = {};
+ try {
+ window.localStorage.removeItem(STORE_KEY);
+ } catch {
+ /* ignore */
+ }
+ this.saved = "";
+ this.dirty = false;
+ this.rebuild();
+ }
+
+ // writable: the visitor owns their home and /tmp; everything else is root's.
+ writable(abs) {
+ return inside(abs, this.home) || inside(abs, "/tmp");
+ }
+
+ owner(abs) {
+ return inside(abs, this.home) ? this.user : "root";
+ }
+
+ inBase(abs) {
+ return lookup(this.base, abs) !== null;
+ }
+
+ get(abs) {
+ return lookup(this.root, abs);
+ }
+
+ // checkCreate reports why abs cannot be created/replaced/removed, or "".
+ checkCreate(abs) {
+ if (abs === "/") return "Permission denied";
+ const parent = this.get(dirname(abs));
+ if (!parent) return "No such file or directory";
+ if (!parent.dir) return "Not a directory";
+ if (!this.writable(dirname(abs)) || !this.writable(abs)) return "Permission denied";
+ return "";
+ }
+
+ mkdir(abs) {
+ const parent = this.get(dirname(abs));
+ const name = basename(abs);
+ const t = Date.now();
+ parent.children[name] = mkDir(name, t);
+ parent.mtime = t;
+ this.entries[abs] = this.inBase(abs) ? { d: 1, m: t, f: 1 } : { d: 1, m: t };
+ this.dirty = true;
+ }
+
+ write(abs, content) {
+ const parent = this.get(dirname(abs));
+ const name = basename(abs);
+ const t = Date.now();
+ if (!parent.children[name]) parent.mtime = t;
+ parent.children[name] = mkFile(name, content, t);
+ this.entries[abs] = { c: content, m: t };
+ this.dirty = true;
+ }
+
+ touch(abs) {
+ const n = this.get(abs);
+ const t = Date.now();
+ n.mtime = t;
+ if (!n.dir) this.entries[abs] = { c: n.content, m: t };
+ else if (this.entries[abs]) this.entries[abs].m = t;
+ this.dirty = true;
+ }
+
+ remove(abs) {
+ const parent = this.get(dirname(abs));
+ delete parent.children[basename(abs)];
+ parent.mtime = Date.now();
+ const prefix = abs + "/";
+ for (const k of Object.keys(this.entries)) if (k.startsWith(prefix)) delete this.entries[k];
+ if (this.inBase(abs)) this.entries[abs] = null;
+ else delete this.entries[abs];
+ this.dirty = true;
+ }
+
+ // copy materializes node (a whole subtree for a directory) at abs.
+ copy(node, abs) {
+ if (this.get(abs)) this.remove(abs);
+ if (!node.dir) {
+ this.write(abs, node.content);
+ return;
+ }
+ this.mkdir(abs);
+ for (const c of sortedChildren(node)) this.copy(c, abs + "/" + c.name);
+ }
+
+ count(n = this.root) {
+ if (!n.dir) return 1;
+ let total = 1;
+ for (const c of Object.values(n.children)) total += this.count(c);
+ return total;
+ }
+}
+
+export { STORE_KEY };
diff --git a/internal/web/scripts/modules/shell-lang.js b/internal/web/scripts/modules/shell-lang.js
@@ -0,0 +1,254 @@
+// A small POSIX-ish command language for the web shell: quoting, escapes,
+// $VAR / ${VAR} / $? expansion, ~, globs, pipes, redirects and ; && ||.
+
+import { lookup, resolvePath, sortedChildren } from "./shell-fs.js";
+
+const OPS = ["2>&1", "2>>", "&&", "||", ">>", "2>", "|", ";", ">", "<", "&"];
+const BREAK = /[\s;|&<>]/;
+
+function escGlob(s) {
+ return s.replace(/[\\*?[\]]/g, "\\$&");
+}
+
+// readVar leaves a \0name\0 marker instead of the value: variables expand
+// when each command runs, so `FOO=bar; echo $FOO` sees the new value.
+function readVar(line, i) {
+ const nx = line[i + 1] ?? "";
+ if (nx === "?" || nx === "$") return { v: `\0${nx}\0`, i: i + 2 };
+ if (nx === "{") {
+ const j = line.indexOf("}", i + 2);
+ if (j < 0) return { v: "$", i: i + 1 };
+ return { v: `\0${line.slice(i + 2, j)}\0`, i: j + 1 };
+ }
+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(line.slice(i + 1));
+ if (!m) return { v: "$", i: i + 1 };
+ return { v: `\0${m[0]}\0`, i: i + 1 + m[0].length };
+}
+
+function varValue(name, env) {
+ if (name === "?") return String(env["?"] ?? 0);
+ if (name === "$") return String(env.$$ ?? 4242);
+ return env[name] ?? "";
+}
+
+// resolveWord substitutes variable markers using the current environment.
+export function resolveWord(w, env) {
+ if (!w.v.includes("\0")) return w;
+ const sub = (str, esc) => str.replace(/\0([^\0]*)\0/g, (_, raw) => {
+ const val = varValue(raw.replace(/\\(.)/g, "$1"), env); // pat markers may be glob-escaped
+ return esc ? escGlob(val) : val;
+ });
+ return { ...w, v: sub(w.v, false), pat: sub(w.pat, true) };
+}
+
+// tokenize returns {toks} or {error}. A word token is {w: {v, pat, glob, quoted}}:
+// v is the literal value, pat the same with quoted glob characters escaped.
+export function tokenize(line, env) {
+ line = line.replace(/\0/g, "");
+ const toks = [];
+ const n = line.length;
+ let i = 0;
+ while (i < n) {
+ const c = line[i];
+ if (c === " " || c === "\t") {
+ i++;
+ continue;
+ }
+ if (c === "#") break;
+ const op = OPS.find((o) => line.startsWith(o, i));
+ if (op) {
+ toks.push({ op });
+ i += op.length;
+ continue;
+ }
+ let v = "";
+ let pat = "";
+ let glob = false;
+ let quoted = false;
+ if (c === "~" && (i + 1 === n || line[i + 1] === "/" || BREAK.test(line[i + 1]))) {
+ v += env.HOME;
+ pat += escGlob(env.HOME);
+ i++;
+ }
+ while (i < n) {
+ const ch = line[i];
+ if (BREAK.test(ch)) break;
+ if (ch === "\\") {
+ const nx = line[i + 1] ?? "";
+ v += nx;
+ pat += escGlob(nx);
+ i += 2;
+ quoted = true;
+ continue;
+ }
+ if (ch === "'") {
+ const j = line.indexOf("'", i + 1);
+ if (j < 0) return { error: "unexpected EOF while looking for matching `''" };
+ const s = line.slice(i + 1, j);
+ v += s;
+ pat += escGlob(s);
+ i = j + 1;
+ quoted = true;
+ continue;
+ }
+ if (ch === '"') {
+ i++;
+ let s = "";
+ while (i < n && line[i] !== '"') {
+ if (line[i] === "\\" && /[$"\\`]/.test(line[i + 1] ?? "")) {
+ s += line[i + 1];
+ i += 2;
+ continue;
+ }
+ if (line[i] === "$") {
+ const r = readVar(line, i);
+ s += r.v;
+ i = r.i;
+ continue;
+ }
+ s += line[i++];
+ }
+ if (i >= n) return { error: "unexpected EOF while looking for matching `\"'" };
+ i++;
+ v += s;
+ pat += escGlob(s);
+ quoted = true;
+ continue;
+ }
+ if (ch === "$") {
+ const r = readVar(line, i);
+ v += r.v;
+ pat += escGlob(r.v);
+ i = r.i;
+ continue;
+ }
+ if (ch === "*" || ch === "?" || ch === "[") glob = true;
+ v += ch;
+ pat += ch;
+ i++;
+ }
+ toks.push({ w: { v, pat, glob, quoted } });
+ }
+ return { toks };
+}
+
+const REDIRS = new Set([">", ">>", "<", "2>", "2>>"]);
+
+// parse turns tokens into [{conn, pipeline: [{words, redirs}]}], conn being
+// the operator that joins an item to the one before it.
+export function parse(toks) {
+ const list = [];
+ let pipeline = [];
+ let cmd = { words: [], redirs: [] };
+ let conn = null;
+ let dangling = false;
+ const empty = () => cmd.words.length === 0 && cmd.redirs.length === 0;
+ const fail = (tok) => ({ error: `syntax error near unexpected token \`${tok}'` });
+
+ for (let k = 0; k < toks.length; k++) {
+ const t = toks[k];
+ if (t.w) {
+ cmd.words.push(t.w);
+ dangling = false;
+ continue;
+ }
+ const op = t.op;
+ if (REDIRS.has(op)) {
+ const nx = toks[k + 1];
+ if (!nx || !nx.w) return fail(nx ? nx.op : "newline");
+ cmd.redirs.push({ op, target: nx.w });
+ k++;
+ dangling = false;
+ continue;
+ }
+ if (op === "2>&1") {
+ cmd.redirs.push({ op });
+ continue;
+ }
+ if (op === "|") {
+ if (empty()) return fail("|");
+ pipeline.push(cmd);
+ cmd = { words: [], redirs: [] };
+ dangling = true;
+ continue;
+ }
+ // ; & && ||
+ if (empty()) return fail(op);
+ pipeline.push(cmd);
+ list.push({ conn, pipeline });
+ pipeline = [];
+ cmd = { words: [], redirs: [] };
+ conn = op === "&" ? ";" : op;
+ dangling = op === "&&" || op === "||";
+ }
+ if (!empty()) pipeline.push(cmd);
+ if (dangling) return { error: "syntax error: unexpected end of file" };
+ if (pipeline.length) list.push({ conn, pipeline });
+ return { list };
+}
+
+function hasMeta(seg) {
+ for (let i = 0; i < seg.length; i++) {
+ if (seg[i] === "\\") i++;
+ else if ("*?[".includes(seg[i])) return true;
+ }
+ return false;
+}
+
+function unescape(seg) {
+ return seg.replace(/\\(.)/g, "$1");
+}
+
+function escRe(c) {
+ return c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
+}
+
+export function globRegex(pat, flags = "") {
+ let re = "";
+ for (let i = 0; i < pat.length; i++) {
+ const c = pat[i];
+ if (c === "\\") re += escRe(pat[++i] ?? "");
+ else if (c === "*") re += "[^/]*";
+ else if (c === "?") re += "[^/]";
+ else if (c === "[") {
+ const j = pat.indexOf("]", i + 2);
+ if (j < 0) {
+ re += "\\[";
+ continue;
+ }
+ let cls = pat.slice(i + 1, j);
+ if (cls[0] === "!") cls = "^" + cls.slice(1);
+ re += "[" + cls.replace(/\\/g, "\\\\") + "]";
+ i = j;
+ } else re += escRe(c);
+ }
+ return new RegExp("^" + re + "$", flags);
+}
+
+// expandWord applies glob expansion against the fs; no match keeps the word.
+export function expandWord(w, root, home, cwd) {
+ if (!w.glob) return [w.v];
+ const absolute = w.pat.startsWith("/");
+ const segs = w.pat.split("/").filter((s) => s !== "");
+ let bases = [absolute ? "/" : ""];
+ const join = (b, name) => (b === "" ? name : b === "/" ? "/" + name : b + "/" + name);
+ for (const seg of segs) {
+ const next = [];
+ for (const b of bases) {
+ if (!hasMeta(seg)) {
+ next.push(join(b, unescape(seg)));
+ continue;
+ }
+ const dir = lookup(root, resolvePath(home, cwd, b || "."));
+ if (!dir || !dir.dir) continue;
+ const re = globRegex(seg);
+ for (const c of sortedChildren(dir)) {
+ if (c.name.startsWith(".") && !seg.startsWith(".")) continue;
+ if (re.test(c.name)) next.push(join(b, c.name));
+ }
+ }
+ bases = next;
+ }
+ const found = bases.filter((p) => lookup(root, resolvePath(home, cwd, p)));
+ return found.length ? found : [w.v];
+}
diff --git a/internal/web/scripts/modules/shell.js b/internal/web/scripts/modules/shell.js
@@ -1,29 +1,30 @@
-// Interactive prompt at the bottom of the about page. Mirrors the SSH shell
-// (internal/tui/shell.go) against the same read-only virtual fs, which the
-// server generates from site content and serves at /api/fs.
+// Interactive prompt at the bottom of the about page. Starts from the same
+// virtual fs as the SSH shell (internal/tui/shell.go, served at /api/fs) and
+// layers the visitor's own changes on top in localStorage (shell-fs.js).
+//
+// It tries to feel like a real terminal over ssh: a little round-trip latency
+// per command, output that streams in instead of appearing at once, typeahead
+// that queues while a command runs, ^C to interrupt, double-tab listings.
import { isEditableTarget } from "./dom.js";
+import { COMMAND_NAMES, SPECIAL, commands, longDate, writeFile } from "./shell-cmds.js";
+import { STORE_KEY, VFS, displayPath, lookup, resolvePath, sortedChildren } from "./shell-fs.js";
+import { expandWord, parse, resolveWord, tokenize } from "./shell-lang.js";
-const COMMANDS = ["cat", "cd", "clear", "echo", "exit", "help", "ls", "pwd", "whoami"];
-const MAX_HISTORY = 200;
-const HELP_TEXT = `commands:
- ls [path] list a directory
- cd [path] change directory (~ is home)
- pwd print working directory
- cat <file> print a file
- clear clear the screen
- help show this
- exit close the session
-
-try: cat about, ls projects, cat projects/janitor/README.md`;
+const MAX_LINES = 2000;
+const MAX_HISTORY = 500;
+const HIST_KEY = "prime8.history.v1";
+const LOGIN_KEY = "prime8.lastlogin.v1";
+const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let fsPromise = null;
-const shellHandlers = { keydown: null };
+const shellHandlers = { keydown: null, storage: null };
function loadFS() {
if (!fsPromise) {
fsPromise = fetch("/api/fs", { credentials: "same-origin" })
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status)))))
+ .then((data) => new VFS(data))
.catch((err) => {
fsPromise = null;
throw err;
@@ -32,45 +33,32 @@ function loadFS() {
return fsPromise;
}
-// --- paths (same rules as internal/vfs) ---
-
-function cleanPath(p) {
- const out = [];
- for (const part of p.split("/")) {
- if (part === "" || part === ".") continue;
- if (part === "..") out.pop();
- else out.push(part);
+function store(key, value) {
+ try {
+ window.localStorage.setItem(key, value);
+ } catch {
+ /* private mode, full, ... */
}
- return "/" + out.join("/");
}
-function resolvePath(home, cwd, p) {
- if (p === "" || p === "~") return home;
- if (p.startsWith("~/")) p = home + p.slice(1);
- else if (!p.startsWith("/")) p = cwd + "/" + p;
- return cleanPath(p);
+function load(key) {
+ try {
+ return window.localStorage.getItem(key);
+ } catch {
+ return null;
+ }
}
-function displayPath(home, abs) {
- if (abs === home) return "~";
- if (abs.startsWith(home + "/")) return "~" + abs.slice(home.length);
- return abs;
+function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
}
-function lookup(root, abs) {
- let n = root;
- for (const part of abs.split("/")) {
- if (part === "") continue;
- if (!n.dir) return null;
- const c = n.children?.[part];
- if (!c) return null;
- n = c;
- }
- return n;
+function jitter(min, max) {
+ return min + Math.random() * (max - min);
}
-function sortedChildren(n) {
- return Object.values(n.children || {}).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
+function nextFrame() {
+ return new Promise((r) => requestAnimationFrame(() => r()));
}
function commonPrefix(words) {
@@ -83,8 +71,6 @@ function commonPrefix(words) {
return first.slice(0, i);
}
-// --- dom helpers ---
-
function span(cls, text) {
const el = document.createElement("span");
el.className = cls;
@@ -120,195 +106,488 @@ export function initShell() {
const cursorEl = live?.querySelector(".shell-cursor");
if (!historyEl || !live || !input || !pathEl || !beforeEl || !afterEl || !cursorEl) return;
- let user = section.dataset.user || "visitor";
- let home = "/home/" + user;
- let cwd = home;
- const recall = [];
- let recallIndex = 0;
+ const initialUser = section.dataset.user || "visitor";
+ let saved = [];
+ try {
+ saved = JSON.parse(load(HIST_KEY) || "[]");
+ } catch {
+ saved = [];
+ }
+
+ // --- shell state ---
+
+ const sh = {
+ fs: null,
+ user: initialUser,
+ home: "/home/" + initialUser,
+ cwd: "/home/" + initialUser,
+ oldpwd: "",
+ status: 0,
+ bootTime: Date.now(),
+ history: Array.isArray(saved) ? saved.filter((l) => typeof l === "string").slice(-MAX_HISTORY) : [],
+ vars: {},
+ get env() {
+ return {
+ HOME: this.home,
+ USER: this.user,
+ LOGNAME: this.user,
+ HOSTNAME: "prime8",
+ SHELL: "/bin/bash",
+ TERM: "xterm-256color",
+ LANG: "en_US.UTF-8",
+ PATH: "/usr/local/bin:/usr/bin:/bin",
+ PWD: this.cwd,
+ ...(this.oldpwd ? { OLDPWD: this.oldpwd } : {}),
+ ...this.vars,
+ "?": this.status,
+ };
+ },
+ resolve(p) {
+ return resolvePath(this.home, this.cwd, p);
+ },
+ chdir(abs) {
+ this.oldpwd = this.cwd;
+ this.cwd = abs;
+ },
+ setVar(k, v) {
+ this.vars[k] = v;
+ },
+ unsetVar(k) {
+ delete this.vars[k];
+ },
+ clearHistory() {
+ this.history.length = 0;
+ store(HIST_KEY, "[]");
+ },
+ };
+
+ let recallIndex = sh.history.length;
let draft = "";
+ let busy = false;
+ let job = null;
+ const typeahead = [];
+ let lastTab = "";
function scrollToPrompt() {
live.scrollIntoView({ block: "nearest" });
}
function render() {
- pathEl.textContent = displayPath(home, cwd);
+ pathEl.textContent = displayPath(sh.home, sh.cwd);
const value = input.value;
const caret = input.selectionStart ?? value.length;
beforeEl.textContent = value.slice(0, caret);
cursorEl.textContent = value.slice(caret, caret + 1) || "\u00a0";
afterEl.textContent = value.slice(caret + 1);
+ holdCursor();
}
- function setInput(value) {
+ // Real terminals restart the blink on every keystroke, so the cursor stays
+ // solid while you type and only starts blinking again once you pause.
+ let blinkTimer = 0;
+ function holdCursor() {
+ cursorEl.classList.add("solid");
+ clearTimeout(blinkTimer);
+ blinkTimer = setTimeout(() => cursorEl.classList.remove("solid"), 530);
+ }
+
+ function setInput(value, caret = value.length) {
input.value = value;
- input.setSelectionRange(value.length, value.length);
+ input.setSelectionRange(caret, caret);
render();
}
+ function trim() {
+ while (historyEl.childElementCount > MAX_LINES) historyEl.firstElementChild?.remove();
+ }
+
function appendLine(nodes, cls) {
const line = document.createElement("div");
line.className = cls;
if (typeof nodes === "string") line.textContent = nodes;
else line.append(...nodes);
historyEl.appendChild(line);
- while (historyEl.childElementCount > MAX_HISTORY * 2) historyEl.firstElementChild?.remove();
+ trim();
+ return line;
}
function clearScreen() {
+ term.reset();
historyEl.replaceChildren();
layout?.classList.add("shell-cleared");
}
- function out(text, err = false) {
- if (text === "") return;
- appendLine(text, err ? "shell-out shell-err" : "shell-out");
+ // --- terminal output: queued lines drained a few per frame ---
+
+ const term = {
+ q: [],
+ open: null,
+ pumping: null,
+ write(text, cls = "") {
+ const parts = text.split("\n");
+ parts.forEach((p, i) => {
+ const end = i < parts.length - 1;
+ if (!end && p === "") return;
+ this.q.push({ text: p, cls, end });
+ });
+ this.kick();
+ },
+ line(nodes, cls = "") {
+ this.q.push({ nodes, cls, end: true });
+ this.kick();
+ },
+ kick() {
+ if (!this.pumping) this.pumping = this.pump();
+ },
+ emit(item) {
+ let el = this.open;
+ if (!el) {
+ el = appendLine("", "shell-out shell-stream" + (item.cls === "err" ? " shell-err" : ""));
+ }
+ if (item.nodes) el.append(...item.nodes);
+ else if (item.text) el.append(document.createTextNode(item.text));
+ if (item.end) {
+ if (!el.textContent) el.textContent = " ";
+ this.open = null;
+ } else this.open = el;
+ },
+ async pump() {
+ // Yield once so push() has stored this promise before we can clear it;
+ // a synchronous finish would leave a settled promise in `pumping` and
+ // drain() would spin on it forever.
+ await null;
+ while (this.q.length) {
+ const n = reducedMotion() ? this.q.length : Math.max(2, Math.ceil(this.q.length / 24));
+ for (let i = 0; i < n && this.q.length; i++) this.emit(this.q.shift());
+ scrollToPrompt();
+ if (this.q.length) await nextFrame();
+ }
+ this.pumping = null;
+ },
+ async drain() {
+ while (this.pumping) await this.pumping;
+ },
+ endLine() {
+ this.open = null;
+ },
+ reset() {
+ this.q.length = 0;
+ this.open = null;
+ },
+ };
+
+ // --- running commands ---
+
+ function newJob() {
+ const j = { aborted: false, wakers: new Set(), exit: false };
+ j.sleep = (ms) =>
+ new Promise((resolve) => {
+ if (j.aborted) return resolve(false);
+ const wake = () => {
+ clearTimeout(t);
+ resolve(false);
+ };
+ const t = setTimeout(() => {
+ j.wakers.delete(wake);
+ resolve(true);
+ }, ms);
+ j.wakers.add(wake);
+ });
+ j.abort = () => {
+ j.aborted = true;
+ for (const w of j.wakers) w();
+ j.wakers.clear();
+ };
+ return j;
}
- function lsNodes(n) {
- if (!n.dir) return [document.createTextNode(n.name)];
- const nodes = [];
- sortedChildren(n).forEach((c, i) => {
- if (i) nodes.push(document.createTextNode(" "));
- nodes.push(c.dir ? span("shell-dir", c.name + "/") : document.createTextNode(c.name));
- });
- return nodes;
+ function expand(words) {
+ const argv = [];
+ for (const raw of words) {
+ const w = resolveWord(raw, sh.env);
+ if (!w.quoted && w.v === "") continue;
+ argv.push(...expandWord(w, sh.fs.root, sh.home, sh.cwd));
+ }
+ return argv;
}
- function exec(root, line) {
- const fields = line.trim().split(/\s+/).filter(Boolean);
- if (fields.length === 0) return;
- const [cmd, ...args] = fields;
+ function shErr(msg) {
+ term.write(`bash: ${msg}\n`, "err");
+ }
- switch (cmd) {
- case "exit":
- case "logout":
- disconnect();
- return;
- case "clear":
- clearScreen();
- return;
- case "help":
- out(HELP_TEXT);
- return;
- case "pwd":
- out(cwd);
- return;
- case "whoami":
- out(user);
- return;
- case "echo":
- out(args.join(" "));
- return;
- case "cd": {
- if (args.length > 1) return out("cd: too many arguments", true);
- const target = args[0] || "";
- const abs = resolvePath(home, cwd, target);
- const n = lookup(root, abs);
- if (!n) return out(`cd: ${target}: No such file or directory`, true);
- if (!n.dir) return out(`cd: ${target}: Not a directory`, true);
- cwd = abs;
- return;
+ async function runCommand(cmd, stdin, last) {
+ const argv = expand(cmd.words);
+
+ // NAME=value with no command sets a shell variable
+ const assign = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
+ if (argv.length && argv.every((a) => assign.test(a)) && cmd.redirs.length === 0) {
+ for (const a of argv) {
+ const m = assign.exec(a);
+ sh.setVar(m[1], m[2]);
}
- case "ls": {
- let paths = args.filter((a) => !a.startsWith("-"));
- if (paths.length === 0) paths = ["."];
- for (const p of paths) {
- const n = lookup(root, resolvePath(home, cwd, p));
- if (!n) {
- out(`ls: cannot access '${p}': No such file or directory`, true);
- continue;
- }
- if (paths.length > 1 && n.dir) out(p + ":");
- const nodes = lsNodes(n);
- if (nodes.length) appendLine(nodes, "shell-out");
+ return { status: 0, stdout: "" };
+ }
+ while (argv.length > 1 && assign.test(argv[0])) argv.shift();
+
+ let input = stdin;
+ let outFile = null;
+ let errFile = null;
+ let errToOut = false;
+ for (const r of cmd.redirs) {
+ if (r.op === "2>&1") {
+ errToOut = true;
+ continue;
+ }
+ const targets = expand([r.target]);
+ if (targets.length !== 1) {
+ shErr(`${resolveWord(r.target, sh.env).v}: ambiguous redirect`);
+ return { status: 1, stdout: "" };
+ }
+ const t = targets[0];
+ if (r.op === "<") {
+ const n = sh.fs.get(sh.resolve(t));
+ if (!n || n.dir) {
+ shErr(`${t}: ${n ? "Is a directory" : "No such file or directory"}`);
+ return { status: 1, stdout: "" };
}
- return;
+ input = n.content;
+ continue;
+ }
+ const append = r.op.endsWith(">>");
+ // the shell creates (and truncates) the target before the command runs
+ const why = writeFile(sh, t, "", true);
+ if (why) {
+ shErr(`${t}: ${why}`);
+ return { status: 1, stdout: "" };
}
- case "cat": {
- if (args.length === 0) return out("cat: missing operand", true);
- for (const p of args) {
- const n = lookup(root, resolvePath(home, cwd, p));
- if (!n) out(`cat: ${p}: No such file or directory`, true);
- else if (n.dir) out(`cat: ${p}: Is a directory`, true);
- else out(n.content || "");
+ if (!append) writeFile(sh, t, "", false);
+ if (r.op.startsWith("2")) errFile = { path: t, buf: "" };
+ else outFile = { path: t, buf: "" };
+ }
+
+ if (argv.length === 0) return { status: 0, stdout: "" };
+ const name = argv[0];
+ const toTerm = last && !outFile;
+ let piped = "";
+ const out = (s) => {
+ if (outFile) outFile.buf += s;
+ else if (toTerm) term.write(s);
+ else piped += s;
+ };
+ const err = (s) => {
+ if (errToOut) out(s);
+ else if (errFile) errFile.buf += s;
+ else term.write(s, "err");
+ };
+
+ let status;
+ if (SPECIAL.has(name)) {
+ if (name === "clear" || name === "reset") {
+ if (toTerm) {
+ await term.drain();
+ clearScreen();
}
- return;
+ } else job.exit = true;
+ status = 0;
+ } else if (commands[name]) {
+ const ctx = {
+ argv,
+ stdin: input,
+ tty: toTerm,
+ sh,
+ out,
+ err,
+ line: (nodes) => term.line(nodes),
+ span,
+ sleep: job.sleep,
+ };
+ try {
+ status = await commands[name](ctx);
+ } catch (e) {
+ err(`${name}: ${e && e.message ? e.message : "internal error"}\n`);
+ status = 1;
}
- case "sudo":
- out(`${user} is not in the sudoers file. This incident will be reported.`, true);
- return;
- default:
- out(`${cmd}: command not found`, true);
+ } else if (name.includes("/")) {
+ const n = sh.fs.get(sh.resolve(name));
+ err(`bash: ${name}: ${n ? (n.dir ? "Is a directory" : "Permission denied") : "No such file or directory"}\n`);
+ status = n ? 126 : 127;
+ } else {
+ err(`${name}: command not found\n`);
+ status = 127;
}
+
+ for (const f of [outFile, errFile]) {
+ if (!f || !f.buf) continue;
+ const why = writeFile(sh, f.path, f.buf, true);
+ if (why) shErr(`${f.path}: ${why}`);
+ }
+ return { status: status ?? 0, stdout: piped };
}
- async function run() {
- const line = input.value;
- const promptCwd = cwd;
- setInput("");
- if (line.trim() && recall[recall.length - 1] !== line) {
- recall.push(line);
- if (recall.length > MAX_HISTORY) recall.shift();
+ async function runLine(line) {
+ const tk = tokenize(line, sh.env);
+ if (tk.error) {
+ shErr(tk.error);
+ sh.status = 2;
+ return;
}
- recallIndex = recall.length;
- draft = "";
+ const parsed = parse(tk.toks);
+ if (parsed.error) {
+ shErr(parsed.error);
+ sh.status = 2;
+ return;
+ }
+ for (const item of parsed.list) {
+ if (item.conn === "&&" && sh.status !== 0) continue;
+ if (item.conn === "||" && sh.status === 0) continue;
+ let stdin = null;
+ for (let i = 0; i < item.pipeline.length; i++) {
+ const r = await runCommand(item.pipeline[i], stdin, i === item.pipeline.length - 1);
+ stdin = r.stdout;
+ sh.status = r.status;
+ if (job.aborted || job.exit) break;
+ }
+ term.endLine();
+ if (job.aborted) {
+ sh.status = 130;
+ return;
+ }
+ if (job.exit) return;
+ }
+ }
+
+ function remember(line) {
+ if (!line.trim() || line.startsWith(" ") || sh.history[sh.history.length - 1] === line) return;
+ sh.history.push(line);
+ if (sh.history.length > MAX_HISTORY) sh.history.shift();
+ store(HIST_KEY, JSON.stringify(sh.history));
+ }
+ async function execute(line) {
+ remember(line);
+ recallIndex = sh.history.length;
+ draft = "";
const first = line.trim().split(/\s+/)[0] || "";
// like the TUI, clear wipes the screen without echoing itself
- if (first !== "clear") appendLine(promptNodes(user, displayPath(home, promptCwd), line), "shell-prompt");
- if (!line.trim()) return scrollToPrompt();
+ if (first !== "clear" && first !== "reset") appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), line), "shell-prompt");
+ if (!line.trim()) return;
- let fs;
+ job = newJob();
+ // network round trip; the first command also waits on the real fetch
+ const rtt = job.sleep(jitter(18, 55));
try {
- fs = await loadFS();
+ sh.fs = await loadFS();
} catch {
- out("prime8: filesystem unavailable, try again in a bit", true);
- return scrollToPrompt();
+ await rtt;
+ term.write("prime8: filesystem unavailable, try again in a bit\n", "err");
+ return;
}
- if (fs.user && fs.user !== user) {
- user = fs.user;
- home = fs.home;
- cwd = home;
+ if (sh.fs.user && sh.fs.user !== sh.user) {
+ sh.user = sh.fs.user;
+ sh.home = sh.fs.home;
+ sh.cwd = sh.home;
}
- exec(fs.root, line);
- render();
+ await rtt;
+ if (!job.aborted) await runLine(line);
+ await term.drain();
+ term.endLine();
+ if (!sh.fs.commit()) term.write("bash: write error: No space left on device\n", "err");
+ await term.drain();
+ if (job.exit) {
+ job = null;
+ disconnect();
+ }
+ }
+
+ async function submit(line) {
+ if (busy) {
+ typeahead.push(line);
+ return;
+ }
+ busy = true;
+ live.classList.add("busy");
+ try {
+ typeahead.unshift(line);
+ while (typeahead.length && !live.hidden) await execute(typeahead.shift());
+ } finally {
+ typeahead.length = 0;
+ job = null;
+ busy = false;
+ live.classList.remove("busy");
+ render();
+ scrollToPrompt();
+ }
+ }
+
+ function interrupt() {
+ if (!busy || !job) return false;
+ typeahead.length = 0;
+ job.abort();
+ term.reset();
+ appendLine("^C", "shell-out shell-stream");
scrollToPrompt();
+ return true;
}
+ // --- completion ---
+
async function complete() {
- const value = input.value;
- if (!value) return;
- const idx = Math.max(value.lastIndexOf(" "), value.lastIndexOf("\t"));
- if (idx < 0) {
- const matches = COMMANDS.filter((c) => c.startsWith(value));
- if (matches.length === 1) setInput(matches[0] + " ");
- else {
- const p = commonPrefix(matches);
- if (p.length > value.length) setInput(p);
+ const value = input.value.slice(0, input.selectionStart ?? input.value.length);
+ const rest = input.value.slice(value.length);
+ const idx = Math.max(value.lastIndexOf(" "), value.lastIndexOf("|"), value.lastIndexOf(";"), value.lastIndexOf(">"), value.lastIndexOf("<"));
+ const word = value.slice(idx + 1);
+ const before = value.slice(0, idx + 1);
+ const commandPos = !/\S/.test(before.replace(/.*[|;&]/, ""));
+ let names;
+ let dirPart = "";
+ let suffix = (m) => m + " ";
+ if (commandPos && !word.includes("/")) {
+ names = COMMAND_NAMES.filter((c) => c.startsWith(word));
+ } else {
+ let fs;
+ try {
+ fs = await loadFS();
+ } catch {
+ return;
}
+ const slash = word.lastIndexOf("/");
+ dirPart = slash >= 0 ? word.slice(0, slash + 1) : "";
+ const base = slash >= 0 ? word.slice(slash + 1) : word;
+ const dir = lookup(fs.root, resolvePath(sh.home, sh.cwd, dirPart || "."));
+ if (!dir || !dir.dir) return;
+ const kids = sortedChildren(dir).filter((c) => c.name.startsWith(base) && (base.startsWith(".") || !c.name.startsWith(".")));
+ names = kids.map((c) => c.name + (c.dir ? "/" : ""));
+ suffix = (m) => (m.endsWith("/") ? m : m + " ");
+ }
+ if (names.length === 0) return;
+ if (names.length === 1) {
+ const done = before + dirPart + suffix(names[0]);
+ setInput(done + rest, done.length);
+ lastTab = "";
return;
}
- let fs;
- try {
- fs = await loadFS();
- } catch {
+ const p = commonPrefix(names);
+ if (p.length > word.length - dirPart.length) {
+ const done = before + dirPart + p;
+ setInput(done + rest, done.length);
+ lastTab = "";
return;
}
- const word = value.slice(idx + 1);
- const slash = word.lastIndexOf("/");
- const dirPart = slash >= 0 ? word.slice(0, slash + 1) : "";
- const base = slash >= 0 ? word.slice(slash + 1) : word;
- const dir = lookup(fs.root, resolvePath(home, cwd, dirPart || "."));
- if (!dir || !dir.dir) return;
- const nodes = sortedChildren(dir).filter((c) => c.name.startsWith(base));
- if (nodes.length === 0) return;
- let completed = commonPrefix(nodes.map((c) => c.name));
- if (nodes.length === 1) completed += nodes[0].dir ? "/" : " ";
- if (input.value === value) setInput(value.slice(0, idx + 1) + dirPart + completed);
+ // second tab with nothing more to fill: list the candidates, like bash
+ if (lastTab === input.value) {
+ appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), input.value), "shell-prompt");
+ appendLine(names.join(" "), "shell-out");
+ scrollToPrompt();
+ lastTab = "";
+ return;
+ }
+ lastTab = input.value;
}
+ // --- session ---
+
function disconnect() {
+ term.reset();
historyEl.replaceChildren();
layout?.classList.add("shell-cleared");
appendLine("logout", "shell-out");
@@ -327,11 +606,17 @@ export function initShell() {
again.focus({ preventScroll: true });
}
- function reconnect() {
+ async function reconnect() {
historyEl.replaceChildren();
- layout?.classList.remove("shell-cleared");
+ // ssh handshake
+ await sleep(jitter(250, 700));
+ const prev = load(LOGIN_KEY);
+ store(LOGIN_KEY, String(Date.now()));
+ if (prev && !Number.isNaN(Number(prev))) appendLine(`Last login: ${longDate(new Date(Number(prev)))} from web`, "shell-out shell-dim");
+ sh.cwd = sh.home;
+ sh.oldpwd = "";
+ sh.status = 0;
live.hidden = false;
- cwd = home;
setInput("");
input.focus({ preventScroll: true });
scrollToPrompt();
@@ -341,26 +626,37 @@ export function initShell() {
const caret = input.selectionStart ?? input.value.length;
const head = input.value.slice(0, caret).replace(/\S+\s*$/, "");
const tail = input.value.slice(caret);
- input.value = head + tail;
- input.setSelectionRange(head.length, head.length);
- render();
+ setInput(head + tail, head.length);
}
input.addEventListener("keydown", (e) => {
if (e.isComposing) return;
+ if (e.key !== "Tab") lastTab = "";
const ctrl = e.ctrlKey && !e.metaKey && !e.altKey;
if (ctrl) {
switch (e.key.toLowerCase()) {
case "l":
e.preventDefault();
- clearScreen();
+ if (!busy) clearScreen();
+ return;
+ case "a":
+ e.preventDefault();
+ setInput(input.value, 0);
+ return;
+ case "e":
+ e.preventDefault();
+ setInput(input.value);
return;
case "u": {
e.preventDefault();
const caret = input.selectionStart ?? 0;
- input.value = input.value.slice(caret);
- input.setSelectionRange(0, 0);
- render();
+ setInput(input.value.slice(caret), 0);
+ return;
+ }
+ case "k": {
+ e.preventDefault();
+ const caret = input.selectionStart ?? input.value.length;
+ setInput(input.value.slice(0, caret), caret);
return;
}
case "w":
@@ -370,14 +666,18 @@ export function initShell() {
case "c":
if (input.selectionStart !== input.selectionEnd) return; // let copy work
e.preventDefault();
- appendLine(promptNodes(user, displayPath(home, cwd), input.value + "^C"), "shell-prompt");
+ if (interrupt()) {
+ setInput("");
+ return;
+ }
+ appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), input.value + "^C"), "shell-prompt");
setInput("");
- recallIndex = recall.length;
+ recallIndex = sh.history.length;
scrollToPrompt();
return;
case "d":
e.preventDefault();
- if (input.value === "") disconnect();
+ if (input.value === "" && !busy) disconnect();
return;
default:
return;
@@ -385,28 +685,33 @@ export function initShell() {
}
switch (e.key) {
- case "Enter":
+ case "Enter": {
e.preventDefault();
- run();
+ const line = input.value;
+ setInput("");
+ submit(line);
break;
+ }
case "Tab":
// keep the tab-cycling shortcut out of the prompt
e.preventDefault();
e.stopPropagation();
- complete();
+ if (!busy) complete();
break;
case "ArrowUp":
e.preventDefault();
e.stopPropagation();
- if (recallIndex === recall.length) draft = input.value;
- if (recallIndex > 0) setInput(recall[--recallIndex]);
+ if (busy) break;
+ if (recallIndex === sh.history.length) draft = input.value;
+ if (recallIndex > 0) setInput(sh.history[--recallIndex]);
break;
case "ArrowDown":
e.preventDefault();
e.stopPropagation();
- if (recallIndex < recall.length) {
+ if (busy) break;
+ if (recallIndex < sh.history.length) {
recallIndex++;
- setInput(recallIndex === recall.length ? draft : recall[recallIndex]);
+ setInput(recallIndex === sh.history.length ? draft : sh.history[recallIndex]);
}
break;
case "Escape":
@@ -430,6 +735,14 @@ export function initShell() {
});
input.addEventListener("blur", () => section.classList.remove("focused"));
+ // another tab changed the files: pick them up
+ if (shellHandlers.storage) window.removeEventListener("storage", shellHandlers.storage);
+ shellHandlers.storage = (e) => {
+ if (e.key !== STORE_KEY || !sh.fs || busy) return;
+ sh.fs.load();
+ };
+ window.addEventListener("storage", shellHandlers.storage);
+
// Typing with nothing focused goes to the prompt, but the tab/help
// shortcuts (digits, ?, space, arrows, tab) keep working, like the TUI.
if (shellHandlers.keydown) document.removeEventListener("keydown", shellHandlers.keydown);
@@ -452,5 +765,6 @@ export function initShell() {
};
document.addEventListener("keydown", shellHandlers.keydown);
+ if (!load(LOGIN_KEY)) store(LOGIN_KEY, String(Date.now()));
render();
}
diff --git a/internal/web/styles/about.css b/internal/web/styles/about.css
@@ -228,6 +228,25 @@
margin-bottom: 3px;
}
+/* streamed output: one div per line, no gap between them */
+.shell-stream {
+ margin-bottom: 0;
+}
+
+/* while a command runs the prompt goes away; typeahead still shows */
+.shell-live.busy .shell-ps1 {
+ display: none;
+}
+
+.shell-match {
+ color: var(--red);
+ font-weight: bold;
+}
+
+.shell-grep-file {
+ color: var(--magenta);
+}
+
.shell-err {
color: var(--red);
}
@@ -271,7 +290,19 @@
animation: cursorBlink 1.1s steps(1) infinite;
}
@keyframes cursorBlink { 50% { background: transparent; color: inherit; } }
+/* solid while typing (blink restarts after a pause, like xterm/Ghostty) */
+.shell-cursor.solid { animation: none; }
+/* unfocused terminal: hollow block, no blink */
+.shell:not(:focus-within) .shell-cursor {
+ animation: none;
+ background: transparent;
+ color: inherit;
+ box-shadow: inset 0 0 0 1px var(--fg);
+}
@media (prefers-reduced-motion: reduce) {
.shell-cursor { animation: none !important; }
}
+
+/* fetch output inside the shell */
+.shell-fetch { margin: 0.4em 0; white-space: normal; }
diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml
@@ -44,7 +44,7 @@
<div class="section shell" id="shell" data-user="{{shellUser}}">
<div class="shell-history" aria-live="polite"></div>
- <label class="shell-prompt shell-live"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-line"><span class="shell-cmd" data-before></span><span class="shell-cursor" aria-hidden="true"> </span><span class="shell-cmd" data-after></span></span><input class="shell-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" aria-label="shell: type help for commands"></label>
+ <label class="shell-prompt shell-live"><span class="shell-ps1"><span class="shell-user">{{shellUser}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> </span><span class="shell-line"><span class="shell-cmd" data-before></span><span class="shell-cursor" aria-hidden="true"> </span><span class="shell-cmd" data-after></span></span><input class="shell-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" aria-label="shell: type help for commands"></label>
</div>
</div>
</section>