prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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:
Ainternal/web/scripts/modules/shell-cmds.js | 1074+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/scripts/modules/shell-fs.js | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/scripts/modules/shell-lang.js | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/web/scripts/modules/shell.js | 698+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Minternal/web/styles/about.css | 31+++++++++++++++++++++++++++++++
Minternal/web/templates/partials/about.gohtml | 2+-
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">&nbsp;</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">&nbsp;</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>