From a9b68ab834b292a5070eee9c4b46fc6f602e59b0 Mon Sep 17 00:00:00 2001 From: "Felix W. Dekker" Date: Fri, 20 Mar 2020 03:41:01 +0100 Subject: [PATCH] Start replacement of code with scripts --- package.json | 2 +- src/main/js/Commands.ts | 751 +++++--------------------------------- src/main/js/FileSystem.ts | 2 + src/main/js/Scripts.ts | 617 +++++++++++++++++++++++++++++++ src/main/js/Shell.ts | 4 +- src/main/js/UserList.ts | 2 +- 6 files changed, 716 insertions(+), 662 deletions(-) create mode 100644 src/main/js/Scripts.ts diff --git a/package.json b/package.json index e51a035..d81468e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fwdekker.com", - "version": "0.28.4", + "version": "0.29.0", "description": "The source code of [my personal website](https://fwdekker.com/).", "author": "Felix W. Dekker", "repository": { diff --git a/src/main/js/Commands.ts b/src/main/js/Commands.ts index ddf5fd9..0476dba 100644 --- a/src/main/js/Commands.ts +++ b/src/main/js/Commands.ts @@ -1,17 +1,17 @@ import "./Extensions"; import {Environment} from "./Environment"; -import {Directory, File, FileSystem, Path} from "./FileSystem"; +import {Directory, File, FileSystem, Node, Path,} from "./FileSystem"; import {InputArgs} from "./InputArgs"; import {InputParser} from "./InputParser"; import {Persistence} from "./Persistence"; -import {escapeHtml, ExpectedGoodbyeError, IllegalArgumentError, IllegalStateError, isStandalone} from "./Shared"; +import {ExpectedGoodbyeError, IllegalArgumentError, IllegalStateError, isStandalone} from "./Shared"; +import {StreamSet} from "./Stream"; import {EscapeCharacters} from "./Terminal"; import {UserList} from "./UserList"; -import {StreamSet} from "./Stream"; /** - * A collection of commands executed within a particular user session. + * A collection of commands that can be executed. */ export class Commands { /** @@ -19,256 +19,26 @@ export class Commands { */ private readonly environment: Environment; /** - * The user session describing the user that executes commands. + * The user list describing the available users. */ - private readonly userSession: UserList; + private readonly userList: UserList; /** * The file system to interact with. */ private readonly fileSystem: FileSystem; - /** - * The list of all available commands. - */ - private readonly commands: { [key: string]: Command }; /** - * Constructs a new collection of commands executed within the given user session. + * Constructs a new collection of commands. * * @param environment the environment in which commands are executed - * @param userSession the user session describing the user that executes commands + * @param userList the user list describing the user that executes commands * @param fileSystem the file system to interact with */ - constructor(environment: Environment, userSession: UserList, fileSystem: FileSystem) { + constructor(environment: Environment, userList: UserList, fileSystem: FileSystem) { this.environment = environment; - this.userSession = userSession; + this.userList = userList; this.fileSystem = fileSystem; - this.commands = { - "and": new Command( - this.and, - `execute command if previous command did not fail`, - `and command`, - `Executes command with its associated options and arguments if and only if the status code of \\\ - the previously-executed command is 0. - - The exit code is retained if it was non-zero, and is changed to that of command otherwise.\\\ - `.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "cat": new Command( - this.cat, - `concatenate and print files`, - `cat [-e | --escape-html] file ...`, - `Reads files sequentially, writing them to the standard output. - - If the file contains valid HTML, it will be displayed as such by default. If the --html \\\ - option is given, special HTML characters are escaped and the raw text contents can be inspected.\\\ - `.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "clear": new Command( - this.clear, - `clear terminal output`, - `clear`, - `Clears all previous terminal output.`, - new InputValidator({maxArgs: 0}) - ), - "cd": new Command( - this.cd, - `change directory`, - `cd [directory]`, - `Changes the current working directory to directory. If no directory is supplied, the \\\ - current working directory is changed to the current user's home directory.`.trimMultiLines(), - new InputValidator({maxArgs: 1}) - ), - "cp": new Command( - this.cp, - `copy files`, - `cp [-r | -R | --recursive] source target file - cp [-r | -R | --recursive] source ... target directory`, - `In its first form, source is copied to target file. This form is used if there is no - file or directory at target file beforehand. - - In its second form, all source files are copied into target directory, which must be a \\\ - pre-existing directory. The file names of the source files are retained. - - In both forms, source files are not copied if they are directories and the -R option \\\ - is not given.`.trimMultiLines(), - new InputValidator({minArgs: 2}) - ), - "echo": new Command( - this.echo, - `display text`, - `echo [-n | --newline] [text ...]`, - `Displays each text separated by a single whitespace. - - Unless the --newline parameter is given, a newline is appended to the end.`.trimMultiLines(), - new InputValidator() - ), - "exit": new Command( - this.exit, - `close session`, - `exit`, - `Closes the terminal session.`, - new InputValidator({maxArgs: 0}) - ), - "help": new Command( - this.help, - `display documentation`, - `help [command ...]`, - `Displays help documentation for each command. - - If no commands are given, a list of all commands is shown.`.trimMultiLines(), - new InputValidator() - ), - "hier": new DocOnlyCommand( - `description of the filesystem hierarchy`, - `A typical josh system has, among others, the following directories: - - / This is the root directory. This is where the whole tree starts. - - /dev Contains special files and device files that refer to physical devices. - - /home Contains directories for users to store personal files in. - - /root The home directory of the root user.`.trimMultiLines() - ), - "ls": new Command( - this.ls, - `list directory contents`, - `ls [-a | -A | --all] [directory ...]`, - `Displays the files and directories in each directory. If no directory is given, the files \\\ - and directories in the current working directory are shown. If more than one directory is given, the \\\ - files and directories are shown for each given directory in order. - - Files starting with a . are only shown if the --all option is given, with the \\\ - exception of . and .., which are always shown.`.trimMultiLines(), - new InputValidator() - ), - "man": new Command( - this.man, - `display manual documentation pages`, - `man page ...`, - `Displays the manual pages with names page. Equivalent to using help if at least one \\\ - page is given.`.trimMultiLines(), - new InputValidator() - ), - "mkdir": new Command( - this.mkdir, - `make directories`, - `mkdir [-p | --parents] directory ...`, - `Creates the directories given by directory. - - If more than one directory is given, the directories are created in the order they are given \\\ - in. If the --parents option is given, parent directories that do not exist are created as \\\ - well.`.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "mv": new Command( - this.mv, - `move files`, - `mv source destination file - mv source ... destination directory`, - `In its first form, source is renamed to target file. target file must not \\\ - exist yet. - - In its second form, all source files are moved into target directory, which must be a \\\ - pre-existing directory. The file names of the source files are retained.`.trimMultiLines(), - new InputValidator({minArgs: 2}) - ), - "not": new Command( - this.not, - `execute command and invert status code`, - `not command`, - `Executes command with its associated options and arguments and inverts its exit code. More \\\ - precisely, the exit code is set to 0 if it was non-zero, and is set to 1 otherwise.`.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "open": new Command( - this.open, - `open web pages`, - `open [-b | --blank] file ...`, - `Opens the web pages linked to by file. The first file is opened in this tab and the \\\ - subsequent files are opened in new tabs. If --blank is set, the first file is \\\ - opened in a new tab as well. - - If this command is executed inside of a standalone app instead of a browser, every file is \\\ - opened in a tab regardless of whether --blank is given.`.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "or": new Command( - this.or, - `execute command if previous command failed`, - `or command`, - `Executes command with its associated options and arguments if and only if the status code of \\\ - the previously-executed command is not 0. - - The exit code is retained if it was zero, and is changed to that of command otherwise.\\\ - `.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "poweroff": new Command( - this.poweroff, - `close down the system`, - `poweroff`, - `Automated shutdown procedure to nicely notify users when the system is shutting down.`, - new InputValidator({maxArgs: 0}) - ), - "pwd": new Command( - this.pwd, - `print working directory`, - `pwd`, - `Displays the current working directory.`, - new InputValidator({maxArgs: 0}) - ), - "rm": new Command( - this.rm, - `remove file`, - `rm [-f | --force] [-r | -R | --recursive] \\\ - [--no-preserve-root] file ...`.trimMultiLines(), - `Removes each given file. If more than one file is given, they are removed in the \\\ - order they are given in. - - If --force is set, no warning is given if a file could not be removed. - - If --recursive is set, files and directories are removed recursively; without this option \\\ - directories cannot be removed. - - Unless --no-preserve-root is set, the root directory cannot be removed.`.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "rmdir": new Command( - this.rmdir, - `remove directories`, - `rmdir directory ...`, - `Removes each given directory. If more than one directory is given, they are removed \\\ - in the order they are given in. Non-empty directories will not be removed.`.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "set": new Command( - this.set, - `set environment variable`, - `set key [value]`, - `Sets the environment variable key to value. If no value is given, the \\\ - environment variable is cleared. Read-only variables cannot be set.`.trimMultiLines(), - new InputValidator({minArgs: 1, maxArgs: 2}) - ), - "touch": new Command( - this.touch, - `change file timestamps`, - `touch file ...`, - `Update the access and modification times of each file to the current time. If a file \\\ - does not exist, it is created.`.trimMultiLines(), - new InputValidator({minArgs: 1}) - ), - "whoami": new Command( - this.whoami, - `print short description of user`, - `whoami`, - `Print a description of the user associated with the current effective user ID.`, - new InputValidator({maxArgs: 0}) - ) - }; } @@ -287,7 +57,7 @@ export class Commands { if (input.command === "") return 0; - const command = this.commands[input.command]; + const command = this.resolve(input.command); if (command === undefined || command instanceof DocOnlyCommand) { streams.err.writeLine(`Unknown command '${input.command}'.`); return -1; @@ -295,7 +65,7 @@ export class Commands { const validation = command.validator.validate(input); if (!validation[0]) { - streams.err.writeLine(this.createUsageErrorOutput(input.command, validation[1])); + streams.err.writeLine(this.createUsageErrorOutput(input.command, command, validation[1])); return -1; } @@ -303,16 +73,91 @@ export class Commands { } /** - * Returns an output action corresponding to an error message about invalid usage of a command. + * Finds the `Command` with the given name and returns it. + * + * @param commandName the name of or path to the command to find + * @return the command addressed by the given name or path, or `undefined` if no such command could be found + */ + private resolve(commandName: string): Command | undefined { + const cwd = this.environment.get("cwd"); + + let script: Node | undefined; + if (commandName.includes("/")) { + script = this.fileSystem.get(Path.interpret(cwd, commandName)); + } else { + script = this.fileSystem.get(Path.interpret(cwd, "/bin", commandName)); + } + if (!(script instanceof File)) { + // TODO: Show error + return undefined; + } + + const code = script.open("read").read(); + try { + return this.interpretScript(code, this.environment, this.userList, this.fileSystem); + } catch (e) { + console.error(`Failed to interpret script '${commandName}'.`, code, e); + return undefined; + } + } + + /** + * Interprets the given code and returns the `Command` it describes. + * + * @param code a string describing a `Command` + * @param environment the environment in which the code is to be executed + * @param userList the list of users relevant to the code + * @param fileSystem the file system to refer to when executing code + * @return the `Command` described by the given code + */ + private interpretScript(code: string, environment: Environment, userList: UserList, + fileSystem: FileSystem): Command { + const __JOSH_Directory = Directory; + const __JOSH_EscapeCharacters = EscapeCharacters; + const __JOSH_File = File; + const __JOSH_Path = Path; + const __JOSH_InputParser = InputParser; + const __JOSH_InputValidator = InputValidator; + const __JOSH_Persistence = Persistence; + { + // noinspection JSUnusedLocalSymbols + const josh = { + "environment": environment, + "fileSystem": fileSystem, + "interpreter": this, + "userList": userList, + "util": { + "isStandalone": isStandalone + } + }; + + // noinspection JSUnusedLocalSymbols + const Directory = __JOSH_Directory; + // noinspection JSUnusedLocalSymbols + const EscapeCharacters = __JOSH_EscapeCharacters; + // noinspection JSUnusedLocalSymbols + const File = __JOSH_File; + // noinspection JSUnusedLocalSymbols + const InputParser = __JOSH_InputParser; + // noinspection JSUnusedLocalSymbols + const InputValidator = __JOSH_InputValidator; + // noinspection JSUnusedLocalSymbols + const Path = __JOSH_Path; + // noinspection JSUnusedLocalSymbols + const Persistence = __JOSH_Persistence; + return eval(code); + } + }; + + /** + * Formats an error message about invalid usage of the given command. * * @param commandName the name of the command that was used incorrectly + * @param command the command of which the input is invalid * @param errorMessage the message describing how the command was used incorrectly; preferably ended with a `.` + * @return an error message about invalid usage of the given command */ - private createUsageErrorOutput(commandName: string, errorMessage: string | undefined): string { - const command = this.commands[commandName]; - if (command === undefined) - throw new IllegalArgumentError(`Unknown command '${commandName}'.`); - + private createUsageErrorOutput(commandName: string, command: Command, errorMessage: string | undefined): string { return `Invalid usage of ${commandName}. ${errorMessage ?? ""} Usage @@ -320,416 +165,6 @@ export class Commands { } - private and(input: InputArgs, streams: StreamSet): number { - const previousStatus = Number(this.environment.getOrDefault("status", "0")); - if (previousStatus !== 0) - return previousStatus; - - return this.execute( - InputParser.create(this.environment, this.fileSystem).parseCommand(input.args), - streams - ); - } - - private cat(input: InputArgs, streams: StreamSet): number { - return input.args - .map(arg => Path.interpret(this.environment.get("cwd"), arg)) - .map(path => { - if (!this.fileSystem.has(path)) { - streams.err.writeLine(`cat: ${path}: No such file.`); - return -1; - } - - const node = this.fileSystem.get(path); - if (!(node instanceof File)) { - streams.err.writeLine(`cat: ${path}: No such file.`); - return -1; - } - - let contents = node.open("read").read(); - if (input.hasAnyOption("e", "--escape-html")) - contents = escapeHtml(contents); - if (!contents.endsWith("\n")) - contents += "\n"; - - streams.out.write(contents); - return 0; - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private cd(input: InputArgs, streams: StreamSet): number { - if (input.argc === 0 || input.args[0] === "") { - this.environment.set("cwd", this.environment.get("home")); - return 0; - } - - const path = Path.interpret(this.environment.get("cwd"), input.args[0]); - if (!this.fileSystem.has(path) || !(this.fileSystem.get(path) instanceof Directory)) { - streams.err.writeLine(`cd: The directory '${path}' does not exist.`); - return -1; - } - - this.environment.set("cwd", path.toString()); - return 0; - } - - private cp(input: InputArgs, streams: StreamSet): number { - let mappings; - try { - mappings = this.moveCopyMappings(input); - } catch (error) { - streams.err.writeLine(`cp: ${error.message}`); - return -1; - } - - return mappings - .map(([source, destination]) => { - try { - this.fileSystem.copy(source, destination, input.hasAnyOption("-r", "-R", "--recursive")); - return 0; - } catch (error) { - streams.err.writeLine(`cp: ${error.message}`); - return -1; - } - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private clear(_: InputArgs, streams: StreamSet): number { - streams.out.write(EscapeCharacters.Escape + EscapeCharacters.Clear); - return 0; - } - - private echo(input: InputArgs, streams: StreamSet): number { - const message = input.args.join(" ").replace("hunter2", "*******"); - - if (input.hasAnyOption("-n", "--newline")) - streams.out.write(message); - else - streams.out.writeLine(message); - - return 0; - } - - private exit(): number { - this.environment.set("user", ""); - return 0; - } - - private help(input: InputArgs, streams: StreamSet): number { - if (input.argc > 0) { - return input.args - .map((commandName, i) => { - if (i > 0) - streams.out.write("\n\n"); - - const command = this.commands[commandName]; - if (command === undefined) { - streams.out.writeLine(`Unknown command '${commandName}'.`); - return -1; - } - - let helpString = "Name\n" + commandName; - if (command.summary !== null) - helpString += "\n\nSummary\n" + command.summary; - if (command.usage !== null) - helpString += "\n\nUsage\n" + command.usage; - if (command.desc !== null) - helpString += "\n\nDescription\n" + command.desc; - - streams.out.writeLine(helpString); - return 0; - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } else { - const commandNames = Object.keys(this.commands) - .filter(it => !(this.commands[it] instanceof DocOnlyCommand)); - - const commandWidth = Math.max.apply(null, commandNames.map(it => it.length)) + 4; - const commandPaddings = commandNames.map(it => commandWidth - it.length); - const commandLinks = commandNames - .map(it => `${it}`) - .map((it, i) => `${it.padEnd(it.length + commandPaddings[i], " ")}`); - const commandEntries = commandNames - .map((it, i) => `${commandLinks[i]}${this.commands[it].summary}`); - - const target = isStandalone() ? `target="_blank"` : ""; - streams.out.writeLine( - `The source code of this website is \\\ - available on git. - - List of commands - ${commandEntries.join("\n")} - - Write "help [COMMAND]" or click a command in the list above for more information.`.trimMultiLines() - ); - return 0; - } - } - - private ls(input: InputArgs, streams: StreamSet): number { - return (input.argc === 0 ? [""] : input.args) - .map(arg => Path.interpret(this.environment.get("cwd"), arg)) - .map((path, i) => { - if (i > 0) - streams.out.write("\n"); - - const node = this.fileSystem.get(path); - if (node === undefined) { - streams.err.writeLine(`ls: The directory '${path}' does not exist.`); - return -1; - } - if (!(node instanceof Directory)) { - streams.err.writeLine(`ls: '${path}' is not a directory.`); - return -1; - } - - const dirList = [ - new Directory().nameString("./", path), - new Directory().nameString("../", path.parent) - ]; - const fileList: string[] = []; - - const nodes = node.nodes; - Object.keys(nodes) - .sortAlphabetically(it => it, true) - .forEach(name => { - const node = nodes[name]; - if (!input.hasAnyOption("-a", "-A", "--all") && name.startsWith(".")) - return; - - if (node instanceof Directory) - dirList.push(node.nameString(`${name}/`, path.getChild(name))); - else if (node instanceof File) - fileList.push(node.nameString(name, path.getChild(name))); - else - throw new IllegalStateError( - `ls: '${path.getChild(name)}' is neither a file nor a directory.`); - }); - - if (input.argc > 1) - streams.out.writeLine(`${path}`); - streams.out.writeLine(dirList.concat(fileList).join("\n")); - return 0; - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private man(input: InputArgs, streams: StreamSet): number { - if (input.argc === 0) { - streams.out.writeLine("What manual page do you want?"); - return 0; - } - - return this.help(input, streams); - } - - private mkdir(input: InputArgs, streams: StreamSet): number { - return input.args - .map(arg => Path.interpret(this.environment.get("cwd"), arg)) - .map(path => { - try { - this.fileSystem.add(path, new Directory(), input.hasAnyOption("-p", "--parents")); - return 0; - } catch (error) { - streams.err.writeLine(`mkdir: ${error.message}`); - return -1; - } - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private mv(input: InputArgs, streams: StreamSet): number { - let mappings; - try { - mappings = this.moveCopyMappings(input); - } catch (error) { - streams.err.writeLine(`mv: ${error.message}`); - return -1; - } - - return mappings - .map(([source, destination]) => { - try { - this.fileSystem.move(source, destination); - return 0; - } catch (error) { - streams.err.writeLine(`mv: ${error.message}`); - return -1; - } - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private not(input: InputArgs, streams: StreamSet): number { - return Number(!this.execute( - InputParser.create(this.environment, this.fileSystem).parseCommand(input.args), - streams - )); - } - - private open(input: InputArgs, streams: StreamSet): number { - return input.args - .map(it => Path.interpret(this.environment.get("cwd"), it)) - .map((path, i) => { - try { - const target = i > 0 || input.hasAnyOption("-b", "--blank") || isStandalone() - ? "_blank" - : "_self"; - window.open(this.fileSystem.open(path, "read").read(), target); - return 0; - } catch (error) { - streams.err.writeLine(`open: ${error.message}`); - return -1; - } - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private or(input: InputArgs, streams: StreamSet): number { - const previousStatus = Number(this.environment.getOrDefault("status", "0")); - if (previousStatus === 0) - return previousStatus; - - return this.execute( - InputParser.create(this.environment, this.fileSystem).parseCommand(input.args), - streams - ); - } - - private poweroff(_: InputArgs, streams: StreamSet): number { - const userName = this.environment.get("user"); - if (userName === "") { - streams.err.writeLine("poweroff: Cannot execute while not logged in."); - return -1; - } - - Persistence.setPoweroff(true); - setTimeout(() => location.reload(), 2000); - - streams.out.writeLine( - `Shutdown NOW! - - *** FINAL System shutdown message from ${userName}@fwdekker.com *** - - System going down IMMEDIATELY - - - System shutdown time has arrived`.trimLines() - ); - return 0; - } - - private pwd(_: InputArgs, streams: StreamSet): number { - streams.out.writeLine(this.environment.get("cwd") ?? ""); - return 0; - } - - private rm(input: InputArgs, streams: StreamSet): number { - return input.args - .map(arg => Path.interpret(this.environment.get("cwd"), arg)) - .map(path => { - try { - const target = this.fileSystem.get(path); - if (target === undefined) { - if (input.hasAnyOption("-f", "--force")) - return 0; - - streams.err.writeLine(`rm: The file '${path}' does not exist.`); - return -1; - } - if (target instanceof Directory) { - if (!input.hasAnyOption("-r", "-R", "--recursive")) { - streams.err.writeLine(`rm: '${path}' is a directory.`); - return -1; - } - if (path.toString() === "/" && !input.hasAnyOption("--no-preserve-root")) { - streams.err.writeLine("rm: Cannot remove root directory."); - return -1; - } - } - - this.fileSystem.remove(path); - return 0; - } catch (error) { - streams.err.writeLine(`rm: ${error.message}`); - return -1; - } - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private rmdir(input: InputArgs, streams: StreamSet): number { - return input.args - .map(arg => Path.interpret(this.environment.get("cwd"), arg)) - .map(path => { - try { - const target = this.fileSystem.get(path); - if (target === undefined) { - streams.err.writeLine(`rmdir: '${path}' does not exist.`); - return -1; - } - if (!(target instanceof Directory)) { - streams.err.writeLine(`rmdir: '${path}' is not a directory.`); - return -1; - } - if (target.nodeCount !== 0) { - streams.err.writeLine(`rmdir: '${path}' is not empty.`); - return -1; - } - - this.fileSystem.remove(path); - return 0; - } catch (error) { - streams.err.writeLine(`rmdir: ${error.message}`); - return -1; - } - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private set(input: InputArgs, streams: StreamSet): number { - try { - if (input.argc === 1) - this.environment.safeDelete(input.args[0]); - else - this.environment.safeSet(input.args[0], input.args[1]); - } catch (error) { - streams.err.writeLine(`set: ${error.message}`); - return -1; - } - - return 0; - } - - private touch(input: InputArgs, streams: StreamSet): number { - return input.args - .map(arg => Path.interpret(this.environment.get("cwd"), arg)) - .map(path => { - try { - this.fileSystem.add(path, new File(), false); - return 0; - } catch (error) { - streams.err.writeLine(`touch: ${error.message}`); - return -1; - } - }) - .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); - } - - private whoami(_: InputArgs, streams: StreamSet): number { - const user = this.userSession.get(this.environment.get("user")); - if (user === undefined) { - streams.err.writeLine("whoami: Cannot execute while not logged in."); - return -1; - } - - streams.out.writeLine(user.description); - return 0; - } - - /** * Maps sources to inputs for the `move` and `copy` commands. * diff --git a/src/main/js/FileSystem.ts b/src/main/js/FileSystem.ts index 9352e5a..06d5221 100644 --- a/src/main/js/FileSystem.ts +++ b/src/main/js/FileSystem.ts @@ -1,3 +1,4 @@ +import {createSlashBin} from "./Scripts"; import {emptyFunction, getFileExtension, IllegalArgumentError} from "./Shared"; import {Stream} from "./Stream"; @@ -21,6 +22,7 @@ export class FileSystem { if (root === undefined) this.root = new Directory({ + "bin": createSlashBin(), "dev": new Directory({ "null": new NullFile() }), diff --git a/src/main/js/Scripts.ts b/src/main/js/Scripts.ts new file mode 100644 index 0000000..6233925 --- /dev/null +++ b/src/main/js/Scripts.ts @@ -0,0 +1,617 @@ +import {Directory, File} from "./FileSystem"; + + +const n = "\\\\\\"; + +// TODO Fix `man`! + + +// noinspection HtmlUnknownAttribute // False positive +/** + * Creates the default scripts in the `/bin` directory. + * + * @return the default scripts in the `/bin` directory + */ +export const createSlashBin = () => new Directory({ + "and": new File(`new Command( + (input, streams) => { + const previousStatus = Number(josh.environment.getOrDefault("status", "0")); + if (previousStatus !== 0) + return previousStatus; + + return josh.interpreter.execute( + InputParser.create(josh.environment, josh.fileSystem).parseCommand(input.args), + streams + ); + }, + \`execute command if previous command did not fail\`, + \`and command\`, + \`Executes command with its associated options and arguments if and only if the status code of the ${n} + previously-executed command is 0. + + The exit code is retained if it was non-zero, and is changed to that of command otherwise.${n} + \`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "cat": new File(`new Command( + (input, streams) => { + return input.args + .map(arg => Path.interpret(josh.environment.get("cwd"), arg)) + .map(path => { + if (!josh.fileSystem.has(path)) { + streams.err.writeLine(\`cat: \${path}: No such file.\`); + return -1; + } + + const node = josh.f.get(path); + if (!(node instanceof File)) { + streams.err.writeLine(\`cat: \${path}: No such file.\`); + return -1; + } + + let contents = node.open("read").read(); + if (input.hasAnyOption("e", "--escape-html")) + contents = escapeHtml(contents); + if (!contents.endsWith("\\n")) + contents += "\\n"; + + streams.out.write(contents); + return 0; + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`concatenate and print files\`, + \`cat [-e | --escape-html] file ...\`, + \`Reads files sequentially, writing them to the standard output. + + If the file contains valid HTML, it will be displayed as such by default. If the --html option is ${n} + given, special HTML characters are escaped and the raw text contents can be inspected.\`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "cd": new File(`new Command( + (input, streams) => { + if (input.argc === 0 || input.args[0] === "") { + josh.environment.set("cwd", josh.environment.get("home")); + return 0; + } + + const path = Path.interpret(josh.environment.get("cwd"), input.args[0]); + if (!josh.fileSystem.has(path) || !(josh.fileSystem.get(path) instanceof Directory)) { + streams.err.writeLine(\`cd: The directory '\${path}' does not exist.\`); + return -1; + } + + josh.environment.set("cwd", path.toString()); + return 0; + }, + \`change directory\`, + \`cd [directory]\`, + \`Changes the current working directory to directory. If no directory is supplied, the ${n} + current working directory is changed to the current user's home directory.\`.trimMultiLines(), + new InputValidator({maxArgs: 1}) + )`), + "cp": new File(`new Command( + (input, streams) => { + let mappings; + try { + mappings = josh.interpreter.moveCopyMappings(input); + } catch (error) { + streams.err.writeLine(\`cp: \${error.message}\`); + return -1; + } + + return mappings + .map(([source, destination]) => { + try { + josh.fileSystem.copy(source, destination, input.hasAnyOption("-r", "-R", "--recursive")); + return 0; + } catch (error) { + streams.err.writeLine(\`cp: \${error.message}\`); + return -1; + } + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`copy files\`, + \`cp [-r | -R | --recursive] source target file + cp [-r | -R | --recursive] source ... target directory\`, + \`In its first form, source is copied to target file. This form is used if there is no file ${n} + or directory at target file beforehand. + + In its second form, all source files are copied into target directory, which must be a ${n} + pre-existing directory. The file names of the source files are retained. + + In both forms, source files are not copied if they are directories and the -R option is not ${n} + given.\`.trimMultiLines(), + new InputValidator({minArgs: 2}) + )`), + "clear": new File(`new Command( + (input, streams) => { + streams.out.write(EscapeCharacters.Escape + EscapeCharacters.Clear); + return 0; + }, + \`clear terminal output\`, + \`clear\`, + \`Clears all previous terminal output.\`, + new InputValidator({maxArgs: 0}) + )`), + "echo": new File(`new Command( + (input, streams) => { + const message = input.args.join(" ").replace("hunter2", "*******"); + + if (input.hasAnyOption("-n", "--newline")) + streams.out.write(message); + else + streams.out.writeLine(message); + + return 0; + }, + \`display text\`, + \`echo [-n | --newline] [text ...]\`, + \`Displays each text separated by a single whitespace. + + Unless the --newline parameter is given, a newline is appended to the end.\`.trimMultiLines(), + new InputValidator() + )`), + "exit": new File(`new Command( + (input, streams) => { + josh.environment.set("user", ""); + return 0; + }, + \`close session\`, + \`exit\`, + \`Closes the terminal session.\`, + new InputValidator({maxArgs: 0}) + )`), + "hier": new File(`new DocOnlyCommand( + \`description of the filesystem hierarchy\`, + \`A typical josh system has, among others, the following directories: + + / This is the root directory. This is where the whole tree starts. + + /bin Executable programs fundamental to user environments. + + /dev Contains special files and device files that refer to physical devices. + + /home Contains directories for users to store personal files in. + + /root The home directory of the root user.\`.trimMultiLines() + )`), + "ls": new File(`new Command( + (input, streams) => { + return (input.argc === 0 ? [""] : input.args) + .map(arg => Path.interpret(josh.environment.get("cwd"), arg)) + .map((path, i) => { + if (i > 0) + streams.out.write("\\n"); + + const node = josh.fileSystem.get(path); + if (node === undefined) { + streams.err.writeLine(\`ls: The directory '\${path}' does not exist.\`); + return -1; + } + if (!(node instanceof Directory)) { + streams.err.writeLine(\`ls: '\${path}' is not a directory.\`); + return -1; + } + + const dirList = [ + new Directory().nameString("./", path), + new Directory().nameString("../", path.parent) + ]; + const fileList = []; + + const nodes = node.nodes; + Object.keys(nodes) + .sortAlphabetically(it => it, true) + .forEach(name => { + const node = nodes[name]; + if (!input.hasAnyOption("-a", "-A", "--all") && name.startsWith(".")) + return; + + if (node instanceof Directory) + dirList.push(node.nameString(\`\${name}/\`, path.getChild(name))); + else if (node instanceof File) + fileList.push(node.nameString(name, path.getChild(name))); + else + throw new IllegalStateError( + \`ls: '\${path.getChild(name)}' is neither a file nor a directory.\`); + }); + + if (input.argc > 1) + streams.out.writeLine(\`\${path}\`); + streams.out.writeLine(dirList.concat(fileList).join("\\n")); + return 0; + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`list directory contents\`, + \`ls [-a | -A | --all] [directory ...]\`, + \`Displays the files and directories in each directory. If no directory is given, the files and ${n} + directories in the current working directory are shown. If more than one directory is given, the files and ${n} + directories are shown for each given directory in order. + + Files starting with a . are only shown if the --all option is given, with the exception of ${n} + . and .., which are always shown.\`.trimMultiLines(), + new InputValidator() + )`), + "man": new File(`new Command( + (input, streams) => { + if (input.argc === 0) { + streams.out.writeLine("What manual page do you want?"); + return 0; + } + + return 0; + + //return josh.interpreter.execute(input, streams); + }, + \`display manual documentation pages\`, + \`man page ...\`, + \`Displays the manual pages with names page. Equivalent to using help if at least one ${n} + page is given.\`.trimMultiLines(), + new InputValidator() + )`), + "help": new File(`new Command( + (input, streams) => { + if (input.argc > 0) { + return input.args + .map((commandName, i) => { + if (i > 0) + streams.out.write("\\n\\n"); + + const command = josh.interpreter.resolve(commandName); + if (command === undefined) { + streams.out.writeLine(\`Unknown command '\${commandName}'.\`); + return -1; + } + + let helpString = "Name\\n" + commandName; + if (command.summary !== null) + helpString += "\\n\\nSummary\\n" + command.summary; + if (command.usage !== null) + helpString += "\\n\\nUsage\\n" + command.usage; + if (command.desc !== null) + helpString += "\\n\\nDescription\\n" + command.desc; + + streams.out.writeLine(helpString); + return 0; + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + } else { + const cwd = josh.environment.get("cwd"); + const slashBin = josh.fileSystem.get(Path.interpret(cwd, "/bin")); + if (!(slashBin instanceof Directory)) { + return -1; + } + + const commands = {}; + Object.keys(slashBin.nodes).map(it => { + const command = josh.interpreter.resolve(it); + if (command !== undefined) commands[it] = command; + }); + const commandNames = Object.keys(commands).filter(it => !(commands[it] instanceof DocOnlyCommand)); + + const commandWidth = Math.max.apply(null, commandNames.map(it => it.length)) + 4; + const commandPaddings = commandNames.map(it => commandWidth - it.length); + const commandLinks = commandNames + .map(it => \`\${it}\`) + .map((it, i) => \`\${it.padEnd(it.length + commandPaddings[i], " ")}\`); + const commandEntries = commandNames + .map((it, i) => \`\${commandLinks[i]}\${commands[it].summary}\`); + + const target = josh.util.isStandalone() ? \`target="_blank"\` : ""; + streams.out.writeLine( + \`The source code of this website is ${n} + available on git. + + List of commands + \${commandEntries.join("\\n")} + + Write "help [COMMAND]" or click a command in the list above for more information.\`.trimMultiLines() + ); + return 0; + } + }, + \`display documentation\`, + \`help [command ...]\`, + \`Displays help documentation for each command. + + If no commands are given, a list of all commands is shown.\`.trimMultiLines(), + new InputValidator() + )`), + "mkdir": new File(`new Command( + (input, streams) => { + return input.args + .map(arg => Path.interpret(josh.environment.get("cwd"), arg)) + .map(path => { + try { + josh.fileSystem.add(path, new Directory(), input.hasAnyOption("-p", "--parents")); + return 0; + } catch (error) { + streams.err.writeLine(\`mkdir: \${error.message}\`); + return -1; + } + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`make directories\`, + \`mkdir [-p | --parents] directory ...\`, + \`Creates the directories given by directory. + + If more than one directory is given, the directories are created in the order they are given in. If ${n} + the --parents option is given, parent directories that do not exist are created as well.${n} + \`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "mv": new File(`new Command( + (input, streams) => { + let mappings; + try { + mappings = josh.interpreter.moveCopyMappings(input); + } catch (error) { + streams.err.writeLine(\`mv: \${error.message}\`); + return -1; + } + + return mappings + .map(([source, destination]) => { + try { + josh.fileSystem.move(source, destination); + return 0; + } catch (error) { + streams.err.writeLine(\`mv: \${error.message}\`); + return -1; + } + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`move files\`, + \`mv source destination file + mv source ... destination directory\`, + \`In its first form, source is renamed to target file. target file must not exist yet. + + In its second form, all source files are moved into target directory, which must be a ${n} + pre-existing directory. The file names of the source files are retained.\`.trimMultiLines(), + new InputValidator({minArgs: 2}) + )`), + "not": new File(`new Command( + (input, streams) => { + return Number(!josh.interpreter.execute( + InputParser.create(josh.environment, josh.fileSystem).parseCommand(input.args), + streams + )); + }, + \`execute command and invert status code\`, + \`not command\`, + \`Executes command with its associated options and arguments and inverts its exit code. More ${n} + precisely, the exit code is set to 0 if it was non-zero, and is set to 1 otherwise.\`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "open": new File(`new Command( + (input, streams) => { + return input.args + .map(it => Path.interpret(josh.environment.get("cwd"), it)) + .map((path, i) => { + try { + const target = i > 0 || input.hasAnyOption("-b", "--blank") || josh.util.isStandalone() + ? "_blank" + : "_self"; + window.open(josh.fileSystem.open(path, "read").read(), target); + return 0; + } catch (error) { + streams.err.writeLine(\`open: \${error.message}\`); + return -1; + } + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`open web pages\`, + \`open [-b | --blank] file ...\`, + \`Opens the web pages linked to by file. The first file is opened in this tab and the ${n} + subsequent files are opened in new tabs. If --blank is set, the first file is opened ${n} + in a new tab as well. + + If this command is executed inside of a standalone app instead of a browser, every file is opened in ${n} + a tab regardless of whether --blank is given.\`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "or": new File(`new Command( + (input, streams) => { + const previousStatus = Number(josh.environment.getOrDefault("status", "0")); + if (previousStatus === 0) + return previousStatus; + + return josh.interpreter.execute( + InputParser.create(josh.environment, josh.fileSystem).parseCommand(input.args), + streams + ); + }, + \`execute command if previous command failed\`, + \`or command\`, + \`Executes command with its associated options and arguments if and only if the status code of the ${n} + previously-executed command is not 0. + + The exit code is retained if it was zero, and is changed to that of command otherwise.${n} + \`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "poweroff": new File(`new Command( + (input, streams) => { + const userName = josh.environment.get("user"); + if (userName === "") { + streams.err.writeLine("poweroff: Cannot execute while not logged in."); + return -1; + } + + Persistence.setPoweroff(true); + setTimeout(() => location.reload(), 2000); + + streams.out.writeLine( + \`Shutdown NOW! + + *** FINAL System shutdown message from \${userName}@fwdekker.com *** + + System going down IMMEDIATELY + + + System shutdown time has arrived\`.trimLines() + ); + return 0; + }, + \`close down the system\`, + \`poweroff\`, + \`Automated shutdown procedure to nicely notify users when the system is shutting down.\`, + new InputValidator({maxArgs: 0}) + )`), + "pwd": new File(`new Command( + (input, streams) => { + streams.out.writeLine(josh.environment.get("cwd") ?? ""); + return 0; + }, + \`print working directory\`, + \`pwd\`, + \`Displays the current working directory.\`, + new InputValidator({maxArgs: 0}) + )`), + "rm": new File(`new Command( + (input, streams) => { + return input.args + .map(arg => Path.interpret(josh.environment.get("cwd"), arg)) + .map(path => { + try { + const target = josh.fileSystem.get(path); + if (target === undefined) { + if (input.hasAnyOption("-f", "--force")) + return 0; + + streams.err.writeLine(\`rm: The file '\${path}' does not exist.\`); + return -1; + } + if (target instanceof Directory) { + if (!input.hasAnyOption("-r", "-R", "--recursive")) { + streams.err.writeLine(\`rm: '\${path}' is a directory.\`); + return -1; + } + if (path.toString() === "/" && !input.hasAnyOption("--no-preserve-root")) { + streams.err.writeLine("rm: Cannot remove root directory."); + return -1; + } + } + + josh.fileSystem.remove(path); + return 0; + } catch (error) { + streams.err.writeLine(\`rm: \${error.message}\`); + return -1; + } + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`remove file\`, + \`rm [-f | --force] [-r | -R | --recursive] ${n} + [--no-preserve-root] file ...\`.trimMultiLines(), + \`Removes each given file. If more than one file is given, they are removed in the order they ${n} + are given in. + + If --force is set, no warning is given if a file could not be removed. + + If --recursive is set, files and directories are removed recursively; without this option ${n} + directories cannot be removed. + + Unless --no-preserve-root is set, the root directory cannot be removed.\`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "rmdir": new File(`new Command( + (input, streams) => { + return input.args + .map(arg => Path.interpret(josh.environment.get("cwd"), arg)) + .map(path => { + try { + const target = josh.fileSystem.get(path); + if (target === undefined) { + streams.err.writeLine(\`rmdir: '\${path}' does not exist.\`); + return -1; + } + if (!(target instanceof Directory)) { + streams.err.writeLine(\`rmdir: '\${path}' is not a directory.\`); + return -1; + } + if (target.nodeCount !== 0) { + streams.err.writeLine(\`rmdir: '\${path}' is not empty.\`); + return -1; + } + + josh.fileSystem.remove(path); + return 0; + } catch (error) { + streams.err.writeLine(\`rmdir: \${error.message}\`); + return -1; + } + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`remove directories\`, + \`rmdir directory ...\`, + \`Removes each given directory. If more than one directory is given, they are removed in the ${n} + order they are given in. Non-empty directories will not be removed.\`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "set": new File(`new Command( + (input, streams) => { + try { + if (input.argc === 1) + josh.environment.safeDelete(input.args[0]); + else + josh.environment.safeSet(input.args[0], input.args[1]); + } catch (error) { + streams.err.writeLine(\`set: \${error.message}\`); + return -1; + } + + return 0; + }, + \`set environment variable\`, + \`set key [value]\`, + \`Sets the environment variable key to value. If no value is given, the environment ${n} + variable is cleared. Read-only variables cannot be set.\`.trimMultiLines(), + new InputValidator({minArgs: 1, maxArgs: 2}) + )`), + "touch": new File(`new Command( + (input, streams) => { + return input.args + .map(arg => Path.interpret(josh.environment.get("cwd"), arg)) + .map(path => { + try { + josh.fileSystem.add(path, new File(), false); + return 0; + } catch (error) { + streams.err.writeLine(\`touch: \${error.message}\`); + return -1; + } + }) + .reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode); + }, + \`change file timestamps\`, + \`touch file ...\`, + \`Update the access and modification times of each file to the current time. If a file does ${n} + not exist, it is created.\`.trimMultiLines(), + new InputValidator({minArgs: 1}) + )`), + "whoami": new File(`new Command( + (input, streams) => { + const user = josh.userList.get(josh.environment.get("user")); + if (user === undefined) { + streams.err.writeLine("whoami: Cannot execute while not logged in."); + return -1; + } + + streams.out.writeLine(user.description); + return 0; + }, + \`print short description of user\`, + \`whoami\`, + \`Print a description of the user associated with the current effective user ID.\`, + new InputValidator({maxArgs: 0}) + )`), +}); diff --git a/src/main/js/Shell.ts b/src/main/js/Shell.ts index 4032800..91bb4e0 100644 --- a/src/main/js/Shell.ts +++ b/src/main/js/Shell.ts @@ -12,7 +12,7 @@ import {InputArgs} from "./InputArgs"; /** - * A shell that interacts with the user session and file system to execute commands. + * A shell that interacts with the environment, user list, file system to execute commands. */ export class Shell { /** @@ -24,7 +24,7 @@ export class Shell { */ private readonly inputHistory: InputHistory; /** - * The user session describing the user that interacts with the shell. + * The user list describing the available users. */ private readonly userList: UserList; /** diff --git a/src/main/js/UserList.ts b/src/main/js/UserList.ts index e945c02..8ab75b2 100644 --- a/src/main/js/UserList.ts +++ b/src/main/js/UserList.ts @@ -1,5 +1,5 @@ /** - * Manages a user session. + * Manages a list of users. */ export class UserList { /**