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();