diff --git a/README.md b/README.md index 416fe5b5..ef517896 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,10 @@ Who needs a website when you have a terminal. - chmod - chown - clear + - cowsay - cp - curl + - date - df - echo - emacs @@ -71,6 +73,7 @@ Who needs a website when you have a terminal. - uname - vi - vim + - wget - zsh Missing a favorite one? Make a PR! diff --git a/config/commands.js b/config/commands.js index 847e4d93..92cc1667 100644 --- a/config/commands.js +++ b/config/commands.js @@ -23,6 +23,25 @@ let killed = false; // Emits a series of styled strings that the RickRoll animation in rickroll.js // interprets as frame pointers. Triggered by `cat id_rsa` or `test`. +// Plays the RickRoll inside the terminal. Every route into the joke goes +// through here — `test`, `cat id_rsa`, `grep id_rsa`, `open test.htm` +// — so none of them can drift back to punting the user out to an external GIF. +function _rickRoll() { + SpawnRickRollPointers(); + if (typeof window.ensureRickRollLoaded !== "function") { + return; + } + window.ensureRickRollLoaded().then(() => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + if (typeof RickRoll === "function") { + RickRoll(); + } + }); + }); + }); +} + function SpawnRickRollPointers() { function padNumber(num, length) { let str = num.toString(); @@ -152,10 +171,6 @@ const commands = { term.displayURL("https://github.com/rootvc"); }, - swag: function () { - term.openURL("https://rootvc.creator-spring.com"); - }, - twitter: function () { term.displayURL("https://twitter.com/rootvc"); term.displayURL("https://twitter.com/machinepix"); @@ -169,6 +184,8 @@ const commands = { term.openURL(`mailto:${firm.email}`); }, + // Points at the static, crawlable mirror of this terminal generated by + // scripts/build-pages.js. Same data, plain HTML, real URLs. www: function () { // There used to be a static mirror at /about/, /portfolio/ and friends. // Those URLs now redirect back into this terminal, so pointing at them @@ -198,6 +215,24 @@ const commands = { term.stylePrint(`(Robot voice): ${message}`); }, + // writeln rather than stylePrint: the cow is alignment-sensitive art, and + // stylePrint would wrap and re-colorize it. + cowsay: function (args) { + const message = args.join(" ") || "moo"; + const bar = " " + "_".repeat(message.length + 2); + const lines = [ + bar, + `< ${message} >`, + " " + "-".repeat(message.length + 2), + " \\ ^__^", + " \\ (oo)\\_______", + " (__)\\ )\\/\\", + " ||----w |", + " || ||", + ]; + lines.forEach((line) => term.writeln(line)); + }, + // Expands ~ to /home/ to mimic a real shell. pwd: function () { term.stylePrint("/" + term.cwd.replaceAll("~", `home/${term.user}`)); @@ -207,6 +242,10 @@ const commands = { term.stylePrint(_filesHere().join(" ")); }, + date: function () { + term.stylePrint(new Date().toString()); + }, + // Simulates a minimal Unix directory tree: // / // ├── bin/ (contains zsh) @@ -332,13 +371,13 @@ const commands = { } }, - // Grepping id_rsa redirects to an appropriate reaction GIF instead. + // Grepping id_rsa gets you an appropriate reaction instead. grep: function (args) { const q = args[0]; const filename = args[1]; if (filename == "id_rsa") { - term.openURL("https://i.imgur.com/Q2Unw.gif"); + _rickRoll(); } if (!q || !filename) { @@ -367,7 +406,7 @@ const commands = { args[0].split(".")[0] == "test" && args[0].split(".")[1] == "htm" ) { - term.openURL("https://i.imgur.com/Q2Unw.gif"); + _rickRoll(); } else if (args[0].split(".")[1] == "htm") { term.openURL(`./${args[0]}`, false); } else if (args.join(" ") == "the pod bay doors") { @@ -619,6 +658,14 @@ const commands = { ); }, + wget: function (args) { + if (!args[0]) { + term.stylePrint("wget: missing URL"); + return; + } + term.stylePrint(`%wget% not installed. Try: %curl% ${args[0]}`); + }, + // scp gets a more redacted treatment than curl. scp: function (args) { term.stylePrint( @@ -638,18 +685,7 @@ const commands = { }, test: function () { - SpawnRickRollPointers(); - if (typeof window.ensureRickRollLoaded === "function") { - window.ensureRickRollLoaded().then(() => { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - if (typeof RickRoll === "function") { - RickRoll(); - } - }); - }); - }); - } + _rickRoll(); }, // ── Upgrade ───────────────────────────────────────────────────────────────── diff --git a/tests/commands.test.js b/tests/commands.test.js index c7ce7e1b..b0003c88 100644 --- a/tests/commands.test.js +++ b/tests/commands.test.js @@ -110,3 +110,93 @@ describe("apply", () => { } }); }); + +// Loads the full command set with a fake terminal. `cd` is the one command with +// real branching logic — a switch over ~, .., /home, /bin and team member names +// — and it drives term.cwd, which the prompt renders on every keystroke. +function loadCommands({ cwd = "~", user = "guest", team = { avidan: {} } } = {}) { + const term = { + cwd, + user, + stylePrint: vi.fn(), + writeln: vi.fn(), + printArt: vi.fn(), + openURL: vi.fn(), + displayURL: vi.fn(), + cols: 100, + }; + const context = vm.createContext({ + term, + jobs: productionJobs, + firm: { blurb: "", email: "hello@example.com" }, + team, + help: {}, + portfolio: {}, + colorText: (text) => text, + window: {}, + }); + vm.runInContext(commandSource, context); + const commands = vm.runInContext("commands", context); + // cd recurses through term.command for the paths that resolve via another cd. + term.command = (line) => { + const [name, ...args] = line.split(" "); + return commands[name](args); + }; + return { commands, term }; +} + +describe("cd", () => { + // Table ported from #51 (@astonm, 2021), which never landed. The cases still + // describe the intended behaviour; only the harness has changed. + it.each([ + ["anywhere", "/", "/"], + ["anywhere", "~", "~"], + ["anywhere", "~/", "~"], + ["~", "..", "home"], + ["~", "../", "home"], + ["anywhere", "../../", "/"], + ["anywhere", "../..", "/"], + ["anywhere", "../../../", "/"], + ["anywhere", "../../../../", "/"], + ["/", "home", "home"], + ["anywhere", "/home", "home"], + ["/", "bin", "bin"], + ["anywhere", ".", "anywhere"], + ["anywhere", "./", "anywhere"], + ["anywhere", "", "~"], + ["anywhere", "/bin", "bin"], + ])("from %s, cd %s -> %s", (cwd, arg, expected) => { + const { commands, term } = loadCommands({ cwd }); + commands.cd(arg === "" ? [] : [arg]); + expect(term.cwd).toBe(expected); + }); + + it("refuses a team member's home directory without moving", () => { + const { commands, term } = loadCommands({ cwd: "home" }); + commands.cd(["avidan"]); + expect(term.cwd).toBe("home"); + expect(term.stylePrint).toHaveBeenCalledWith( + "You do not have permission to access this directory" + ); + }); + + it("lets a user into their own home but not someone else's", () => { + const mine = loadCommands({ cwd: "home", user: "guest" }); + mine.commands.cd(["guest"]); + expect(mine.term.cwd).toBe("~"); + + const theirs = loadCommands({ cwd: "home", user: "guest" }); + theirs.commands.cd(["root"]); + expect(theirs.term.cwd).toBe("home"); + expect(theirs.term.stylePrint).toHaveBeenCalledWith( + "You do not have permission to access this directory" + ); + }); + + it("reports unknown directories without moving", () => { + const { commands, term } = loadCommands({ cwd: "~" }); + commands.cd(["nope"]); + expect(term.cwd).toBe("~"); + expect(term.stylePrint).toHaveBeenCalledWith("No such directory: nope"); + }); +});