prime8dev

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

shell-cmds.js (36425B)


      1 // Commands for the web shell. Each takes a ctx and returns an exit status:
      2 //   ctx.argv     argv, argv[0] is the command name
      3 //   ctx.stdin    piped / redirected input, or null
      4 //   ctx.out(s)   stdout text, ctx.err(s) stderr text
      5 //   ctx.tty      stdout is the terminal; ctx.line(nodes) writes a styled line
      6 //   ctx.sh       shell state (fs, user, home, cwd, env, history, ...)
      7 //   ctx.sleep(ms) resolves false if the command was interrupted (^C)
      8 
      9 import { basename, dirname, displayPath, inside, nodeSize, sortedChildren } from "./shell-fs.js";
     10 import { globRegex } from "./shell-lang.js";
     11 
     12 export const USAGE = {
     13   cat: "cat [-n] [file...]       print files (or stdin)",
     14   cd: "cd [dir|-]               change directory (~ is home, - is the last one)",
     15   clear: "clear                    clear the screen",
     16   cp: "cp [-r] src... dest       copy files and directories",
     17   date: "date                     print the date and time",
     18   echo: "echo [-n] [-e] [text...]  print text",
     19   env: "env                      print environment variables",
     20   exit: "exit                     close the session",
     21   export: "export [NAME=value...]   set environment variables",
     22   false: "false                    do nothing, unsuccessfully",
     23   fetch: "fetch                    system info, the same card as the top of the page",
     24   file: "file path...             guess what a file is",
     25   find: "find [path] [-name pat] [-iname pat] [-type f|d]",
     26   grep: "grep [-invcrlwo] pattern [file...]  search for a regex",
     27   head: "head [-n N] [file...]     first N lines (default 10)",
     28   help: "help                     list commands",
     29   history: "history [-c]             command history (-c clears it)",
     30   hostname: "hostname                 print the host name",
     31   id: "id                       print user and group ids",
     32   ls: "ls [-adlR1] [path...]     list directory contents",
     33   man: "man command              show usage for a command",
     34   mkdir: "mkdir [-p] dir...         make directories",
     35   mv: "mv src... dest           move or rename",
     36   ping: "ping [-c N] host         ping a host (^C to stop)",
     37   pwd: "pwd                      print working directory",
     38   restore: "restore                  drop your changes, back to the original files",
     39   rm: "rm [-rf] path...          remove files and directories",
     40   rmdir: "rmdir dir...             remove empty directories",
     41   seq: "seq [first [step]] last  print a sequence of numbers",
     42   sleep: "sleep seconds            wait (^C to stop)",
     43   sort: "sort [-rnu] [file...]     sort lines",
     44   stat: "stat path...             file status",
     45   tail: "tail [-n N] [file...]     last N lines (default 10)",
     46   tee: "tee [-a] file...         copy stdin to files and stdout",
     47   touch: "touch file...            create files or bump their mtime",
     48   tree: "tree [-a] [path]         draw a directory tree",
     49   true: "true                     do nothing, successfully",
     50   type: "type name...             say what a command is",
     51   uname: "uname [-a]               print system information",
     52   uniq: "uniq [-c] [file...]       collapse repeated lines",
     53   unset: "unset NAME...            remove environment variables",
     54   uptime: "uptime                   how long this session has been up",
     55   wc: "wc [-lwc] [file...]       count lines, words and bytes",
     56   which: "which command...         locate a command",
     57   whoami: "whoami                   print the user name",
     58 };
     59 
     60 export const HELP_TEXT = `prime8 shell. what you create here is saved in this browser.
     61 
     62   navigate   ls cd pwd tree find stat file
     63   read       cat head tail wc grep sort uniq
     64   write      echo touch mkdir cp mv rm rmdir tee
     65   system     fetch whoami id hostname uname date uptime env export ping sleep seq
     66   session    clear history man restore exit
     67 
     68 pipes |, redirects > >> < 2>, ; && ||, globs * ? [ ], $VARS and ~ all work.
     69 man <cmd> for usage. try: cat about, ls projects, echo hi > notes && cat notes`;
     70 
     71 const BUILTINS = new Set(["cd", "echo", "exit", "export", "history", "pwd", "type", "unset", "help", "true", "false", "logout"]);
     72 const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
     73 const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
     74 
     75 // --- helpers ---
     76 
     77 // flags splits leading -abc style options from operands. Options in withValue
     78 // take the next argument (or the rest of the cluster) as their value.
     79 function flags(args, withValue = "") {
     80   const set = {};
     81   const rest = [];
     82   let i = 0;
     83   for (; i < args.length; i++) {
     84     const a = args[i];
     85     if (a === "--") {
     86       i++;
     87       break;
     88     }
     89     if (!a.startsWith("-") || a === "-" || /^-\d/.test(a)) {
     90       rest.push(a);
     91       continue;
     92     }
     93     for (let j = 1; j < a.length; j++) {
     94       const f = a[j];
     95       if (withValue.includes(f)) {
     96         set[f] = a.slice(j + 1) || args[++i];
     97         break;
     98       }
     99       set[f] = true;
    100     }
    101   }
    102   for (; i < args.length; i++) rest.push(args[i]);
    103   return { set, rest };
    104 }
    105 
    106 function lines(text) {
    107   if (text === "") return [];
    108   const ls = text.split("\n");
    109   if (ls[ls.length - 1] === "") ls.pop();
    110   return ls;
    111 }
    112 
    113 function joinLines(ls) {
    114   return ls.length ? ls.join("\n") + "\n" : "";
    115 }
    116 
    117 function pad2(n) {
    118   return String(n).padStart(2, "0");
    119 }
    120 
    121 function lsDate(ms) {
    122   const d = new Date(ms);
    123   const old = Date.now() - ms > 182 * 86400e3;
    124   const tail = old ? String(d.getFullYear()).padStart(5) : `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
    125   return `${MONTHS[d.getMonth()]} ${String(d.getDate()).padStart(2)} ${tail}`;
    126 }
    127 
    128 function longDate(d = new Date()) {
    129   const tz = new Intl.DateTimeFormat("en-US", { timeZoneName: "short" }).formatToParts(d).find((p) => p.type === "timeZoneName")?.value || "";
    130   return `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${String(d.getDate()).padStart(2)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} ${tz} ${d.getFullYear()}`;
    131 }
    132 
    133 function perms(sh, abs, n) {
    134   const w = sh.fs.writable(abs) ? "w" : "-";
    135   if (n.dir) return abs === "/tmp" ? "drwxrwxrwt" : `dr${w}xr-xr-x`;
    136   return `-r${w}-r--r--`;
    137 }
    138 
    139 // readInputs yields [name, text] for each file operand, or stdin with none.
    140 function readInputs(ctx, files) {
    141   const out = [];
    142   if (files.length === 0) {
    143     out.push(["", ctx.stdin ?? ""]);
    144     return out;
    145   }
    146   for (const f of files) {
    147     if (f === "-") {
    148       out.push(["-", ctx.stdin ?? ""]);
    149       continue;
    150     }
    151     const n = ctx.sh.fs.get(ctx.sh.resolve(f));
    152     if (!n) ctx.err(`${ctx.argv[0]}: ${f}: No such file or directory\n`);
    153     else if (n.dir) ctx.err(`${ctx.argv[0]}: ${f}: Is a directory\n`);
    154     else out.push([f, n.content]);
    155   }
    156   return out;
    157 }
    158 
    159 function nameNode(ctx, n, label = n.name) {
    160   return n.dir ? ctx.span("shell-dir", label) : document.createTextNode(label);
    161 }
    162 
    163 // --- commands ---
    164 
    165 const commands = {};
    166 
    167 commands.help = (ctx) => {
    168   ctx.out(HELP_TEXT + "\n");
    169   return 0;
    170 };
    171 
    172 commands.man = (ctx) => {
    173   const name = ctx.argv[1];
    174   if (!name) {
    175     ctx.err("What manual page do you want?\nFor example, try 'man man'.\n");
    176     return 1;
    177   }
    178   if (name === "man") {
    179     ctx.out(USAGE.man + "\n");
    180     return 0;
    181   }
    182   const u = USAGE[name];
    183   if (!u) {
    184     ctx.err(`No manual entry for ${name}\n`);
    185     return 16;
    186   }
    187   ctx.out(`${name.toUpperCase()}(1)\n\n  ${u}\n`);
    188   return 0;
    189 };
    190 
    191 commands.pwd = (ctx) => {
    192   ctx.out(ctx.sh.cwd + "\n");
    193   return 0;
    194 };
    195 
    196 commands.whoami = (ctx) => {
    197   ctx.out(ctx.sh.user + "\n");
    198   return 0;
    199 };
    200 
    201 commands.id = (ctx) => {
    202   const u = ctx.sh.user;
    203   ctx.out(`uid=1000(${u}) gid=1000(${u}) groups=1000(${u})\n`);
    204   return 0;
    205 };
    206 
    207 commands.hostname = (ctx) => {
    208   ctx.out("prime8\n");
    209   return 0;
    210 };
    211 
    212 commands.uname = (ctx) => {
    213   const { set } = flags(ctx.argv.slice(1));
    214   ctx.out(set.a ? "Linux prime8 6.12.0-prime8 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux\n" : "Linux\n");
    215   return 0;
    216 };
    217 
    218 // fetch reprints the fetch card from the top of the about page.
    219 commands.fetch = async (ctx) => {
    220   const card = document.querySelector("#about .fetch");
    221   if (!card) {
    222     ctx.err("fetch: no system info\n");
    223     return 1;
    224   }
    225   await ctx.sleep(60 + Math.random() * 80); // probing hardware...
    226   if (ctx.tty) {
    227     const copy = card.cloneNode(true);
    228     copy.classList.add("shell-fetch");
    229     copy.querySelector("img")?.removeAttribute("loading");
    230     ctx.line([copy]);
    231   } else {
    232     const rows = [...card.querySelectorAll(".fetch-title, .fetch-rule, .fetch-row")];
    233     ctx.out(rows.map((r) => r.textContent.trim()).join("\n") + "\n");
    234   }
    235   return 0;
    236 };
    237 
    238 commands.date = (ctx) => {
    239   ctx.out(longDate() + "\n");
    240   return 0;
    241 };
    242 
    243 commands.uptime = (ctx) => {
    244   const d = new Date();
    245   const mins = Math.floor((Date.now() - ctx.sh.bootTime) / 60000);
    246   const up = mins < 60 ? `${mins} min` : `${Math.floor(mins / 60)}:${pad2(mins % 60)}`;
    247   const load = () => (Math.random() * 0.2).toFixed(2);
    248   ctx.out(` ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} up ${up},  1 user,  load average: ${load()}, ${load()}, ${load()}\n`);
    249   return 0;
    250 };
    251 
    252 commands.true = () => 0;
    253 commands.false = () => 1;
    254 
    255 commands.echo = (ctx) => {
    256   let args = ctx.argv.slice(1);
    257   let newline = true;
    258   let escapes = false;
    259   while (args.length && /^-[neE]+$/.test(args[0])) {
    260     if (args[0].includes("n")) newline = false;
    261     if (args[0].includes("e")) escapes = true;
    262     args = args.slice(1);
    263   }
    264   let s = args.join(" ");
    265   if (escapes) s = s.replace(/\\([ntr\\])/g, (_, c) => ({ n: "\n", t: "\t", r: "\r", "\\": "\\" })[c]);
    266   ctx.out(s + (newline ? "\n" : ""));
    267   return 0;
    268 };
    269 
    270 commands.env = (ctx) => {
    271   const env = ctx.sh.env;
    272   ctx.out(joinLines(Object.keys(env).filter((k) => /^[A-Za-z_]/.test(k)).sort().map((k) => `${k}=${env[k]}`)));
    273   return 0;
    274 };
    275 
    276 commands.export = (ctx) => {
    277   const args = ctx.argv.slice(1).filter((a) => a !== "-p");
    278   if (args.length === 0) {
    279     const env = ctx.sh.env;
    280     ctx.out(joinLines(Object.keys(env).filter((k) => /^[A-Za-z_]/.test(k)).sort().map((k) => `declare -x ${k}="${env[k]}"`)));
    281     return 0;
    282   }
    283   let status = 0;
    284   for (const a of args) {
    285     const m = /^([A-Za-z_][A-Za-z0-9_]*)(?:=(.*))?$/s.exec(a);
    286     if (!m) {
    287       ctx.err(`bash: export: \`${a}': not a valid identifier\n`);
    288       status = 1;
    289     } else if (m[2] !== undefined) ctx.sh.setVar(m[1], m[2]);
    290   }
    291   return status;
    292 };
    293 
    294 commands.unset = (ctx) => {
    295   for (const a of ctx.argv.slice(1)) ctx.sh.unsetVar(a);
    296   return 0;
    297 };
    298 
    299 commands.history = (ctx) => {
    300   const { set } = flags(ctx.argv.slice(1));
    301   if (set.c) {
    302     ctx.sh.clearHistory();
    303     return 0;
    304   }
    305   const h = ctx.sh.history;
    306   const width = String(h.length).length;
    307   ctx.out(joinLines(h.map((l, i) => `${String(i + 1).padStart(Math.max(width, 4))}  ${l}`)));
    308   return 0;
    309 };
    310 
    311 commands.cd = (ctx) => {
    312   const sh = ctx.sh;
    313   const args = ctx.argv.slice(1);
    314   if (args.length > 1) {
    315     ctx.err("bash: cd: too many arguments\n");
    316     return 1;
    317   }
    318   let target = args[0] ?? "";
    319   if (target === "-") {
    320     if (!sh.oldpwd) {
    321       ctx.err("bash: cd: OLDPWD not set\n");
    322       return 1;
    323     }
    324     target = sh.oldpwd;
    325     ctx.out(target + "\n");
    326   }
    327   const abs = sh.resolve(target);
    328   const n = sh.fs.get(abs);
    329   if (!n) {
    330     ctx.err(`bash: cd: ${target}: No such file or directory\n`);
    331     return 1;
    332   }
    333   if (!n.dir) {
    334     ctx.err(`bash: cd: ${target}: Not a directory\n`);
    335     return 1;
    336   }
    337   sh.chdir(abs);
    338   return 0;
    339 };
    340 
    341 commands.ls = async (ctx) => {
    342   const sh = ctx.sh;
    343   const { set, rest } = flags(ctx.argv.slice(1));
    344   const all = set.a || set.A;
    345   const long = set.l;
    346   const perLine = set["1"] || !ctx.tty;
    347   const recursive = set.R;
    348   const paths = rest.length ? rest : ["."];
    349   let status = 0;
    350 
    351   const entriesOf = (abs, n) => {
    352     const kids = sortedChildren(n).filter((c) => all || !c.name.startsWith("."));
    353     const list = kids.map((c) => ({ label: c.name, node: c, abs: abs === "/" ? "/" + c.name : abs + "/" + c.name }));
    354     if (set.a) {
    355       list.unshift({ label: "..", node: sh.fs.get(dirname(abs)), abs: dirname(abs) }, { label: ".", node: n, abs });
    356       list.sort((a, b) => (a.label < b.label ? -1 : 1));
    357     }
    358     return list;
    359   };
    360 
    361   const show = (list) => {
    362     if (long) {
    363       const sizes = list.map((e) => String(nodeSize(e.node)));
    364       const width = Math.max(1, ...sizes.map((s) => s.length));
    365       const owners = list.map((e) => sh.fs.owner(e.abs));
    366       const ow = Math.max(1, ...owners.map((s) => s.length));
    367       list.forEach((e, i) => {
    368         const links = e.node.dir ? 2 + Object.values(e.node.children).filter((c) => c.dir).length : 1;
    369         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)} `;
    370         if (ctx.tty) ctx.line([document.createTextNode(head), nameNode(ctx, e.node, e.label)]);
    371         else ctx.out(head + e.label + "\n");
    372       });
    373       return;
    374     }
    375     if (perLine) {
    376       for (const e of list) {
    377         if (ctx.tty) ctx.line([nameNode(ctx, e.node, e.label)]);
    378         else ctx.out(e.label + "\n");
    379       }
    380       return;
    381     }
    382     if (list.length === 0) return;
    383     const nodes = [];
    384     list.forEach((e, i) => {
    385       if (i) nodes.push(document.createTextNode("  "));
    386       nodes.push(nameNode(ctx, e.node, e.label));
    387     });
    388     ctx.line(nodes);
    389   };
    390 
    391   const files = [];
    392   const dirs = [];
    393   for (const p of paths) {
    394     const abs = sh.resolve(p);
    395     const n = sh.fs.get(abs);
    396     if (!n) {
    397       ctx.err(`ls: cannot access '${p}': No such file or directory\n`);
    398       status = 2;
    399     } else if (n.dir && !set.d) dirs.push({ p, abs, n });
    400     else files.push({ label: p, node: n, abs });
    401   }
    402   if (files.length) show(files);
    403   const headers = dirs.length + files.length > 1 || recursive;
    404   let first = files.length === 0;
    405   const walk = async (p, abs, n) => {
    406     if (!first) ctx.out("\n");
    407     first = false;
    408     if (headers) ctx.out(`${p}:\n`);
    409     const list = entriesOf(abs, n);
    410     if (long) ctx.out(`total ${list.reduce((t, e) => t + Math.ceil(nodeSize(e.node) / 4096) * 4, 0)}\n`);
    411     show(list);
    412     if (!recursive) return;
    413     for (const e of list) {
    414       if (!e.node.dir || e.label === "." || e.label === "..") continue;
    415       if (!(await ctx.sleep(4))) return;
    416       await walk(p === "/" ? "/" + e.label : `${p}/${e.label}`, e.abs, e.node);
    417     }
    418   };
    419   for (const d of dirs) await walk(d.p, d.abs, d.n);
    420   return status;
    421 };
    422 
    423 commands.cat = (ctx) => {
    424   const { set, rest } = flags(ctx.argv.slice(1));
    425   const inputs = readInputs(ctx, rest);
    426   let text = inputs.map(([, t]) => t).join("");
    427   if (set.n) text = joinLines(lines(text).map((l, i) => `${String(i + 1).padStart(6)}\t${l}`));
    428   ctx.out(text);
    429   return inputs.length === Math.max(rest.length, 1) ? 0 : 1;
    430 };
    431 
    432 function headTail(ctx, tail) {
    433   const args = ctx.argv.slice(1).map((a) => (/^-\d+$/.test(a) ? "-n" + a.slice(1) : a));
    434   const { set, rest } = flags(args, "n");
    435   const n = set.n === undefined ? 10 : parseInt(set.n, 10);
    436   if (Number.isNaN(n)) {
    437     ctx.err(`${ctx.argv[0]}: invalid number of lines: '${set.n}'\n`);
    438     return 1;
    439   }
    440   const inputs = readInputs(ctx, rest);
    441   inputs.forEach(([name, text], i) => {
    442     if (inputs.length > 1) ctx.out(`${i ? "\n" : ""}==> ${name} <==\n`);
    443     const ls = lines(text);
    444     ctx.out(joinLines(tail ? ls.slice(Math.max(ls.length - n, 0)) : ls.slice(0, n)));
    445   });
    446   return inputs.length === Math.max(rest.length, 1) ? 0 : 1;
    447 }
    448 
    449 commands.head = (ctx) => headTail(ctx, false);
    450 commands.tail = (ctx) => headTail(ctx, true);
    451 
    452 commands.wc = (ctx) => {
    453   const { set, rest } = flags(ctx.argv.slice(1));
    454   const pick = set.l || set.w || set.c ? set : { l: true, w: true, c: true };
    455   const inputs = readInputs(ctx, rest);
    456   const rows = inputs.map(([name, t]) => [(t.match(/\n/g) || []).length, (t.match(/\S+/g) || []).length, new TextEncoder().encode(t).length, name]);
    457   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"]);
    458   const cols = (pick.l ? 1 : 0) + (pick.w ? 1 : 0) + (pick.c ? 1 : 0);
    459   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);
    460   for (const r of rows) {
    461     const cols = [];
    462     if (pick.l) cols.push(String(r[0]).padStart(width));
    463     if (pick.w) cols.push(String(r[1]).padStart(width));
    464     if (pick.c) cols.push(String(r[2]).padStart(width));
    465     ctx.out(cols.join(" ") + (r[3] && r[3] !== "-" ? " " + r[3] : "") + "\n");
    466   }
    467   return inputs.length === Math.max(rest.length, 1) ? 0 : 1;
    468 };
    469 
    470 commands.grep = async (ctx) => {
    471   const sh = ctx.sh;
    472   const { set, rest } = flags(ctx.argv.slice(1), "e");
    473   const pattern = set.e ?? rest.shift();
    474   if (pattern === undefined) {
    475     ctx.err("Usage: grep [OPTION]... PATTERNS [FILE]...\nTry 'grep --help' for more information.\n");
    476     return 2;
    477   }
    478   let src = pattern;
    479   if (set.F) src = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    480   if (set.w) src = `\\b(?:${src})\\b`;
    481   let re;
    482   try {
    483     re = new RegExp(src, set.i ? "gi" : "g");
    484   } catch {
    485     re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), set.i ? "gi" : "g");
    486   }
    487   const recursive = set.r || set.R;
    488   const inputs = [];
    489   let status = 1;
    490   let errors = false;
    491   if (rest.length === 0) {
    492     if (recursive) rest.push(".");
    493     else inputs.push(["", ctx.stdin ?? ""]);
    494   }
    495   const collect = (label, abs, n) => {
    496     if (!n.dir) {
    497       inputs.push([label, n.content]);
    498       return;
    499     }
    500     for (const c of sortedChildren(n)) collect(label === "." && !recursive ? c.name : `${label}/${c.name}`, `${abs === "/" ? "" : abs}/${c.name}`, c);
    501   };
    502   for (const f of rest) {
    503     const abs = sh.resolve(f);
    504     const n = sh.fs.get(abs);
    505     if (!n) {
    506       if (!set.s) ctx.err(`grep: ${f}: No such file or directory\n`);
    507       errors = true;
    508     } else if (n.dir && !recursive) {
    509       ctx.err(`grep: ${f}: Is a directory\n`);
    510       errors = true;
    511     } else collect(f.replace(/\/+$/, "") || f, abs, n);
    512   }
    513   if (recursive) await ctx.sleep(Math.min(300, inputs.length * 3));
    514   const prefix = inputs.length > 1 || recursive;
    515   for (const [name, text] of inputs) {
    516     let count = 0;
    517     const ls = lines(text);
    518     for (let i = 0; i < ls.length; i++) {
    519       re.lastIndex = 0;
    520       const hit = re.test(ls[i]);
    521       if (hit === !!set.v) continue;
    522       count++;
    523       status = 0;
    524       if (set.l || set.c || set.q) continue;
    525       const head = `${prefix ? name + ":" : ""}${set.n ? i + 1 + ":" : ""}`;
    526       if (set.o && !set.v) {
    527         re.lastIndex = 0;
    528         for (const m of ls[i].matchAll(re)) {
    529           if (m[0] === "") continue;
    530           if (ctx.tty) ctx.line([...(head ? [ctx.span("shell-grep-file", head)] : []), ctx.span("shell-match", m[0])]);
    531           else ctx.out(head + m[0] + "\n");
    532         }
    533         continue;
    534       }
    535       if (!ctx.tty || set.v) {
    536         if (ctx.tty) ctx.line([ctx.span("shell-grep-file", head), document.createTextNode(ls[i])]);
    537         else ctx.out(head + ls[i] + "\n");
    538         continue;
    539       }
    540       const nodes = head ? [ctx.span("shell-grep-file", head)] : [];
    541       let last = 0;
    542       re.lastIndex = 0;
    543       for (const m of ls[i].matchAll(re)) {
    544         if (m[0] === "") break;
    545         nodes.push(document.createTextNode(ls[i].slice(last, m.index)), ctx.span("shell-match", m[0]));
    546         last = m.index + m[0].length;
    547       }
    548       nodes.push(document.createTextNode(ls[i].slice(last)));
    549       ctx.line(nodes);
    550     }
    551     if (set.c) ctx.out(`${prefix ? name + ":" : ""}${count}\n`);
    552     else if (set.l && count) ctx.out(name + "\n");
    553   }
    554   return errors && status !== 0 ? 2 : status;
    555 };
    556 
    557 commands.sort = (ctx) => {
    558   const { set, rest } = flags(ctx.argv.slice(1));
    559   let ls = readInputs(ctx, rest).flatMap(([, t]) => lines(t));
    560   if (set.n) ls.sort((a, b) => (parseFloat(a) || 0) - (parseFloat(b) || 0));
    561   else ls.sort((a, b) => (set.f ? a.toLowerCase().localeCompare(b.toLowerCase()) : a < b ? -1 : a > b ? 1 : 0));
    562   if (set.r) ls.reverse();
    563   if (set.u) ls = ls.filter((l, i) => i === 0 || l !== ls[i - 1]);
    564   ctx.out(joinLines(ls));
    565   return 0;
    566 };
    567 
    568 commands.uniq = (ctx) => {
    569   const { set, rest } = flags(ctx.argv.slice(1));
    570   const ls = readInputs(ctx, rest.slice(0, 1)).flatMap(([, t]) => lines(t));
    571   const out = [];
    572   for (const l of ls) {
    573     const prev = out[out.length - 1];
    574     if (prev && prev.l === l) prev.n++;
    575     else out.push({ l, n: 1 });
    576   }
    577   const rows = out.filter((r) => (set.d ? r.n > 1 : set.u ? r.n === 1 : true));
    578   ctx.out(joinLines(rows.map((r) => (set.c ? `${String(r.n).padStart(7)} ${r.l}` : r.l))));
    579   return 0;
    580 };
    581 
    582 commands.seq = (ctx) => {
    583   const nums = ctx.argv.slice(1).map(Number);
    584   if (nums.length === 0 || nums.length > 3 || nums.some(Number.isNaN)) {
    585     ctx.err(nums.length === 0 ? "seq: missing operand\n" : `seq: invalid argument\n`);
    586     return 1;
    587   }
    588   const [first, step, last] = nums.length === 1 ? [1, 1, nums[0]] : nums.length === 2 ? [nums[0], 1, nums[1]] : nums;
    589   if (step === 0) {
    590     ctx.err("seq: invalid Zero increment value: '0'\n");
    591     return 1;
    592   }
    593   const out = [];
    594   for (let x = first; step > 0 ? x <= last : x >= last; x += step) {
    595     out.push(String(Math.round(x * 1e9) / 1e9));
    596     if (out.length >= 100000) break;
    597   }
    598   ctx.out(joinLines(out));
    599   return 0;
    600 };
    601 
    602 // writeOut is how commands create or replace files; it reports errors as
    603 // "<cmd>: <how>: <path>: <reason>" style through err and returns false.
    604 export function writeFile(sh, path, content, append) {
    605   const abs = sh.resolve(path);
    606   if (abs === "/dev/null") return "";
    607   const n = sh.fs.get(abs);
    608   if (n && n.dir) return "Is a directory";
    609   const why = sh.fs.checkCreate(abs);
    610   if (why) return why;
    611   sh.fs.write(abs, append && n ? n.content + content : content);
    612   return "";
    613 }
    614 
    615 commands.tee = (ctx) => {
    616   const { set, rest } = flags(ctx.argv.slice(1));
    617   const text = ctx.stdin ?? "";
    618   let status = 0;
    619   for (const f of rest) {
    620     const why = writeFile(ctx.sh, f, text, set.a);
    621     if (why) {
    622       ctx.err(`tee: ${f}: ${why}\n`);
    623       status = 1;
    624     }
    625   }
    626   ctx.out(text);
    627   return status;
    628 };
    629 
    630 commands.touch = async (ctx) => {
    631   const sh = ctx.sh;
    632   const { rest } = flags(ctx.argv.slice(1));
    633   if (rest.length === 0) {
    634     ctx.err("touch: missing file operand\n");
    635     return 1;
    636   }
    637   let status = 0;
    638   for (const f of rest) {
    639     const abs = sh.resolve(f);
    640     const n = sh.fs.get(abs);
    641     if (n) {
    642       if (!sh.fs.writable(abs)) {
    643         ctx.err(`touch: cannot touch '${f}': Permission denied\n`);
    644         status = 1;
    645       } else sh.fs.touch(abs);
    646       continue;
    647     }
    648     const why = sh.fs.checkCreate(abs);
    649     if (why) {
    650       ctx.err(`touch: cannot touch '${f}': ${why}\n`);
    651       status = 1;
    652     } else sh.fs.write(abs, "");
    653   }
    654   await ctx.sleep(6 + Math.random() * 10);
    655   return status;
    656 };
    657 
    658 commands.mkdir = async (ctx) => {
    659   const sh = ctx.sh;
    660   const { set, rest } = flags(ctx.argv.slice(1));
    661   if (rest.length === 0) {
    662     ctx.err("mkdir: missing operand\n");
    663     return 1;
    664   }
    665   let status = 0;
    666   for (const d of rest) {
    667     const abs = sh.resolve(d);
    668     if (set.p) {
    669       let cur = "";
    670       for (const part of abs.split("/").filter(Boolean)) {
    671         cur += "/" + part;
    672         const n = sh.fs.get(cur);
    673         if (n && n.dir) continue;
    674         const why = n ? "File exists" : sh.fs.checkCreate(cur);
    675         if (why) {
    676           ctx.err(`mkdir: cannot create directory '${d}': ${why}\n`);
    677           status = 1;
    678           break;
    679         }
    680         sh.fs.mkdir(cur);
    681       }
    682       continue;
    683     }
    684     const why = sh.fs.get(abs) ? "File exists" : sh.fs.checkCreate(abs);
    685     if (why) {
    686       ctx.err(`mkdir: cannot create directory '${d}': ${why}\n`);
    687       status = 1;
    688     } else sh.fs.mkdir(abs);
    689   }
    690   await ctx.sleep(6 + Math.random() * 10);
    691   return status;
    692 };
    693 
    694 commands.rmdir = (ctx) => {
    695   const sh = ctx.sh;
    696   const { rest } = flags(ctx.argv.slice(1));
    697   if (rest.length === 0) {
    698     ctx.err("rmdir: missing operand\n");
    699     return 1;
    700   }
    701   let status = 0;
    702   for (const d of rest) {
    703     const abs = sh.resolve(d);
    704     const n = sh.fs.get(abs);
    705     let why = "";
    706     if (!n) why = "No such file or directory";
    707     else if (!n.dir) why = "Not a directory";
    708     else if (Object.keys(n.children).length) why = "Directory not empty";
    709     else why = sh.fs.checkCreate(abs);
    710     if (why) {
    711       ctx.err(`rmdir: failed to remove '${d}': ${why}\n`);
    712       status = 1;
    713     } else sh.fs.remove(abs);
    714   }
    715   return status;
    716 };
    717 
    718 commands.rm = async (ctx) => {
    719   const sh = ctx.sh;
    720   const { set, rest } = flags(ctx.argv.slice(1));
    721   const recursive = set.r || set.R;
    722   if (rest.length === 0) {
    723     if (set.f) return 0;
    724     ctx.err("rm: missing operand\n");
    725     return 1;
    726   }
    727   let status = 0;
    728   let removed = 0;
    729   for (const p of rest) {
    730     const abs = sh.resolve(p);
    731     if (/(^|\/)\.\.?\/*$/.test(p)) {
    732       ctx.err(`rm: refusing to remove '.' or '..' directory: skipping '${p}'\n`);
    733       status = 1;
    734       continue;
    735     }
    736     if (abs === "/" && recursive) {
    737       ctx.err("rm: it is dangerous to operate recursively on '/'\nrm: use --no-preserve-root to override this failsafe\n");
    738       status = 1;
    739       continue;
    740     }
    741     const n = sh.fs.get(abs);
    742     if (!n) {
    743       if (!set.f) {
    744         ctx.err(`rm: cannot remove '${p}': No such file or directory\n`);
    745         status = 1;
    746       }
    747       continue;
    748     }
    749     if (n.dir && !recursive) {
    750       ctx.err(`rm: cannot remove '${p}': Is a directory\n`);
    751       status = 1;
    752       continue;
    753     }
    754     const why = sh.fs.checkCreate(abs);
    755     if (why) {
    756       ctx.err(`rm: cannot remove '${p}': ${why}\n`);
    757       status = 1;
    758       continue;
    759     }
    760     removed += sh.fs.count(n);
    761     sh.fs.remove(abs);
    762   }
    763   await ctx.sleep(Math.min(250, 4 + removed * 2));
    764   return status;
    765 };
    766 
    767 function transfer(ctx, move) {
    768   const sh = ctx.sh;
    769   const name = ctx.argv[0];
    770   const { set, rest } = flags(ctx.argv.slice(1));
    771   const recursive = set.r || set.R || set.a;
    772   if (rest.length < 2) {
    773     ctx.err(rest.length ? `${name}: missing destination file operand after '${rest[0]}'\n` : `${name}: missing file operand\n`);
    774     return 1;
    775   }
    776   const destArg = rest.pop();
    777   const destAbs = sh.resolve(destArg);
    778   const destNode = sh.fs.get(destAbs);
    779   if (rest.length > 1 && !(destNode && destNode.dir)) {
    780     ctx.err(`${name}: target '${destArg}': Not a directory\n`);
    781     return 1;
    782   }
    783   let status = 0;
    784   for (const src of rest) {
    785     const srcAbs = sh.resolve(src);
    786     const node = sh.fs.get(srcAbs);
    787     if (!node) {
    788       ctx.err(`${name}: cannot stat '${src}': No such file or directory\n`);
    789       status = 1;
    790       continue;
    791     }
    792     if (node.dir && !move && !recursive) {
    793       ctx.err(`cp: -r not specified; omitting directory '${src}'\n`);
    794       status = 1;
    795       continue;
    796     }
    797     const target = destNode && destNode.dir ? (destAbs === "/" ? "" : destAbs) + "/" + basename(srcAbs) : destAbs;
    798     if (target === srcAbs) {
    799       ctx.err(`${name}: '${src}' and '${destArg}' are the same file\n`);
    800       status = 1;
    801       continue;
    802     }
    803     if (node.dir && inside(target, srcAbs)) {
    804       ctx.err(`${name}: cannot ${move ? "move" : "copy"} '${src}' to a subdirectory of itself, '${displayPath(sh.home, target)}'\n`);
    805       status = 1;
    806       continue;
    807     }
    808     const existing = sh.fs.get(target);
    809     if (existing && existing.dir && !node.dir) {
    810       ctx.err(`${name}: cannot overwrite directory '${displayPath(sh.home, target)}' with non-directory\n`);
    811       status = 1;
    812       continue;
    813     }
    814     if (existing && !existing.dir && node.dir) {
    815       ctx.err(`${name}: cannot overwrite non-directory '${displayPath(sh.home, target)}' with directory '${src}'\n`);
    816       status = 1;
    817       continue;
    818     }
    819     const why = sh.fs.checkCreate(target) || (move ? sh.fs.checkCreate(srcAbs) : "");
    820     if (why) {
    821       ctx.err(`${name}: cannot ${move ? "move" : "create"} '${src}': ${why}\n`);
    822       status = 1;
    823       continue;
    824     }
    825     sh.fs.copy(node, target);
    826     if (move) sh.fs.remove(srcAbs);
    827   }
    828   return status;
    829 }
    830 
    831 commands.cp = async (ctx) => {
    832   const status = transfer(ctx, false);
    833   await ctx.sleep(8 + Math.random() * 20);
    834   return status;
    835 };
    836 
    837 commands.mv = async (ctx) => {
    838   const status = transfer(ctx, true);
    839   await ctx.sleep(5 + Math.random() * 10);
    840   return status;
    841 };
    842 
    843 commands.find = async (ctx) => {
    844   const sh = ctx.sh;
    845   const args = ctx.argv.slice(1);
    846   const roots = [];
    847   while (args.length && !args[0].startsWith("-")) roots.push(args.shift());
    848   if (roots.length === 0) roots.push(".");
    849   let nameRe = null;
    850   let type = "";
    851   let maxDepth = Infinity;
    852   while (args.length) {
    853     const a = args.shift();
    854     const v = args.shift();
    855     if (v === undefined) {
    856       ctx.err(`find: missing argument to \`${a}'\n`);
    857       return 1;
    858     }
    859     if (a === "-name" || a === "-iname") nameRe = globRegex(v, a === "-iname" ? "i" : "");
    860     else if (a === "-type") type = v;
    861     else if (a === "-maxdepth") maxDepth = parseInt(v, 10);
    862     else {
    863       ctx.err(`find: unknown predicate \`${a}'\n`);
    864       return 1;
    865     }
    866   }
    867   let status = 0;
    868   let visited = 0;
    869   const out = [];
    870   const walk = (label, n, d) => {
    871     visited++;
    872     const ok = (!nameRe || nameRe.test(n.name || "/")) && (!type || (type === "d" ? n.dir : type === "f" ? !n.dir : false));
    873     if (ok) out.push([label, n]);
    874     if (n.dir && d < maxDepth) for (const c of sortedChildren(n)) walk(label.endsWith("/") ? label + c.name : `${label}/${c.name}`, c, d + 1);
    875   };
    876   for (const r of roots) {
    877     const n = sh.fs.get(sh.resolve(r));
    878     if (!n) {
    879       ctx.err(`find: '${r}': No such file or directory\n`);
    880       status = 1;
    881       continue;
    882     }
    883     walk(r, n, 0);
    884   }
    885   await ctx.sleep(Math.min(400, 10 + visited * 2));
    886   for (const [label, n] of out) {
    887     if (ctx.tty) ctx.line([nameNode(ctx, n, label)]);
    888     else ctx.out(label + "\n");
    889   }
    890   return status;
    891 };
    892 
    893 commands.tree = async (ctx) => {
    894   const sh = ctx.sh;
    895   const { set, rest } = flags(ctx.argv.slice(1));
    896   const p = rest[0] ?? ".";
    897   const root = sh.fs.get(sh.resolve(p));
    898   if (!root) {
    899     ctx.err(`${p} [error opening dir]\n\n0 directories, 0 files\n`);
    900     return 2;
    901   }
    902   let dirs = 0;
    903   let files = 0;
    904   const emit = (prefix, label, n) => {
    905     if (ctx.tty) ctx.line([document.createTextNode(prefix), nameNode(ctx, n, label)]);
    906     else ctx.out(prefix + label + "\n");
    907   };
    908   emit("", p, root);
    909   const walk = (n, prefix) => {
    910     const kids = sortedChildren(n).filter((c) => set.a || !c.name.startsWith("."));
    911     kids.forEach((c, i) => {
    912       const last = i === kids.length - 1;
    913       emit(prefix + (last ? "└── " : "├── "), c.name, c);
    914       if (c.dir) {
    915         dirs++;
    916         walk(c, prefix + (last ? "    " : "│   "));
    917       } else files++;
    918     });
    919   };
    920   if (root.dir) walk(root, "");
    921   await ctx.sleep(Math.min(300, (dirs + files) * 2));
    922   ctx.out(`\n${dirs} director${dirs === 1 ? "y" : "ies"}, ${files} file${files === 1 ? "" : "s"}\n`);
    923   return 0;
    924 };
    925 
    926 commands.stat = (ctx) => {
    927   const sh = ctx.sh;
    928   const args = ctx.argv.slice(1);
    929   if (args.length === 0) {
    930     ctx.err("stat: missing operand\n");
    931     return 1;
    932   }
    933   let status = 0;
    934   for (const p of args) {
    935     const abs = sh.resolve(p);
    936     const n = sh.fs.get(abs);
    937     if (!n) {
    938       ctx.err(`stat: cannot statx '${p}': No such file or directory\n`);
    939       status = 1;
    940       continue;
    941     }
    942     const size = nodeSize(n);
    943     const owner = sh.fs.owner(abs);
    944     const id = owner === "root" ? 0 : 1000;
    945     const when = new Date(n.mtime);
    946     const ts = `${when.getFullYear()}-${pad2(when.getMonth() + 1)}-${pad2(when.getDate())} ${pad2(when.getHours())}:${pad2(when.getMinutes())}:${pad2(when.getSeconds())}`;
    947     ctx.out(
    948       `  File: ${p}\n  Size: ${size}\tBlocks: ${Math.ceil(size / 512)}\tIO Block: 4096   ${n.dir ? "directory" : size ? "regular file" : "regular empty file"}\n` +
    949         `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` +
    950         `Modify: ${ts}\n`,
    951     );
    952   }
    953   return status;
    954 };
    955 
    956 commands.file = (ctx) => {
    957   const sh = ctx.sh;
    958   let status = 0;
    959   for (const p of ctx.argv.slice(1)) {
    960     const n = sh.fs.get(sh.resolve(p));
    961     let kind;
    962     if (!n) {
    963       kind = "cannot open (No such file or directory)";
    964       status = 1;
    965     } else if (n.dir) kind = "directory";
    966     else if (n.content === "") kind = "empty";
    967     else if (/^#!/.test(n.content)) kind = "script, ASCII text executable";
    968     else if (/^\s*[{[]/.test(n.content) && (() => { try { JSON.parse(n.content); return true; } catch { return false; } })()) kind = "JSON text data";
    969     else kind = /^[\x00-\x7f]*$/.test(n.content) ? "ASCII text" : "Unicode text, UTF-8 text";
    970     ctx.out(`${p}: ${kind}\n`);
    971   }
    972   return status;
    973 };
    974 
    975 commands.which = (ctx) => {
    976   let status = 0;
    977   for (const c of ctx.argv.slice(1)) {
    978     if (commands[c] && !BUILTINS.has(c)) ctx.out(`/usr/bin/${c}\n`);
    979     else status = 1;
    980   }
    981   return status;
    982 };
    983 
    984 commands.type = (ctx) => {
    985   let status = 0;
    986   for (const c of ctx.argv.slice(1)) {
    987     if (BUILTINS.has(c)) ctx.out(`${c} is a shell builtin\n`);
    988     else if (commands[c]) ctx.out(`${c} is /usr/bin/${c}\n`);
    989     else {
    990       ctx.err(`bash: type: ${c}: not found\n`);
    991       status = 1;
    992     }
    993   }
    994   return status;
    995 };
    996 
    997 commands.sleep = async (ctx) => {
    998   const arg = ctx.argv[1];
    999   const m = arg && /^(\d*\.?\d+)([smh]?)$/.exec(arg);
   1000   if (!m) {
   1001     ctx.err(arg ? `sleep: invalid time interval '${arg}'\n` : "sleep: missing operand\n");
   1002     return 1;
   1003   }
   1004   const secs = parseFloat(m[1]) * ({ s: 1, m: 60, h: 3600 }[m[2]] ?? 1);
   1005   await ctx.sleep(secs * 1000);
   1006   return 0;
   1007 };
   1008 
   1009 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" };
   1010 
   1011 commands.ping = async (ctx) => {
   1012   const { set, rest } = flags(ctx.argv.slice(1), "c");
   1013   const host = rest[0];
   1014   if (!host) {
   1015     ctx.err("ping: usage error: Destination address required\n");
   1016     return 1;
   1017   }
   1018   const ip = HOSTS[host];
   1019   if (!ip) {
   1020     await ctx.sleep(400 + Math.random() * 600);
   1021     ctx.err(`ping: ${host}: Temporary failure in name resolution\n`);
   1022     return 2;
   1023   }
   1024   const count = set.c ? parseInt(set.c, 10) : ctx.tty ? 100 : 4;
   1025   const local = ip.startsWith("127.");
   1026   const base = local ? 0.04 : 14 + Math.random() * 12;
   1027   const times = [];
   1028   const start = Date.now();
   1029   ctx.out(`PING ${host} (${ip}) 56(84) bytes of data.\n`);
   1030   for (let seq = 1; seq <= count; seq++) {
   1031     const t = local ? base + Math.random() * 0.03 : base + Math.random() * 4 + (Math.random() < 0.08 ? Math.random() * 30 : 0);
   1032     if (!(await ctx.sleep(seq === 1 ? t : 1000))) break;
   1033     times.push(t);
   1034     ctx.out(`64 bytes from ${host} (${ip}): icmp_seq=${seq} ttl=${local ? 64 : 54} time=${t.toFixed(local ? 3 : 1)} ms\n`);
   1035   }
   1036   const n = times.length;
   1037   const avg = n ? times.reduce((a, b) => a + b, 0) / n : 0;
   1038   const mdev = n ? Math.sqrt(times.reduce((a, b) => a + (b - avg) ** 2, 0) / n) : 0;
   1039   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`);
   1040   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`);
   1041   return 0;
   1042 };
   1043 
   1044 commands.restore = (ctx) => {
   1045   const n = ctx.sh.fs.changes();
   1046   ctx.sh.fs.reset();
   1047   if (!ctx.sh.fs.get(ctx.sh.cwd)) ctx.sh.chdir(ctx.sh.home);
   1048   ctx.out(n ? `restored the original files (${n} change${n === 1 ? "" : "s"} dropped)\n` : "nothing to restore\n");
   1049   return 0;
   1050 };
   1051 
   1052 commands.sudo = (ctx) => {
   1053   ctx.err(`${ctx.sh.user} is not in the sudoers file.  This incident will be reported.\n`);
   1054   return 1;
   1055 };
   1056 
   1057 commands.su = async (ctx) => {
   1058   await ctx.sleep(2000 + Math.random() * 800);
   1059   ctx.err("su: Authentication failure\n");
   1060   return 1;
   1061 };
   1062 
   1063 for (const ed of ["vi", "vim", "nvim", "nano", "hx", "emacs", "ed"]) {
   1064   commands[ed] = (ctx) => {
   1065     ctx.err(`${ed}: no editor in this shell. try: echo "hello" > notes, or echo more >> notes\n`);
   1066     return 127;
   1067   };
   1068 }
   1069 
   1070 // exit, logout, clear and reset need the terminal; shell.js handles them.
   1071 export const SPECIAL = new Set(["exit", "logout", "clear", "reset"]);
   1072 
   1073 export { commands, longDate };
   1074 export const COMMAND_NAMES = [...Object.keys(commands).filter((c) => !["vi", "vim", "nvim", "nano", "emacs", "ed", "hx"].includes(c)), ...SPECIAL].sort();