forked from tools/josh
1
0
Fork 0

Start replacement of code with scripts

This commit is contained in:
Florine W. Dekker 2020-03-20 03:41:01 +01:00
parent a362583bbe
commit a9b68ab834
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
6 changed files with 716 additions and 662 deletions

View File

@ -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": {

View File

@ -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 <u>command</u>`,
`Executes <u>command</u> 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 <u>command</u> otherwise.\\\
`.trimMultiLines(),
new InputValidator({minArgs: 1})
),
"cat": new Command(
this.cat,
`concatenate and print files`,
`cat [<b>-e</b> | <b>--escape-html</b>] <u>file</u> <u>...</u>`,
`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 <b>--html</b> \\\
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 [<u>directory</u>]`,
`Changes the current working directory to <u>directory</u>. If no <u>directory</u> 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 [<b>-r</b> | <b>-R</b> | <b>--recursive</b>] <u>source</u> <u>target file</u>
cp [<b>-r</b> | <b>-R</b> | <b>--recursive</b>] <u>source</u> <u>...</u> <u>target directory</u>`,
`In its first form, <u>source</u> is copied to <u>target file</u>. This form is used if there is no
file or directory at <u>target file</u> beforehand.
In its second form, all <u>source</u> files are copied into <u>target directory</u>, which must be a \\\
pre-existing directory. The file names of the <u>source</u> files are retained.
In both forms, <u>source</u> files are not copied if they are directories and the <b>-R</b> option \\\
is not given.`.trimMultiLines(),
new InputValidator({minArgs: 2})
),
"echo": new Command(
this.echo,
`display text`,
`echo [<b>-n</b> | <b>--newline</b>] [<u>text</u> <u>...</u>]`,
`Displays each <u>text</u> separated by a single whitespace.
Unless the <b>--newline</b> 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 [<u>command</u> <u>...</u>]`,
`Displays help documentation for each <u>command</u>.
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:
<u>/</u> This is the root directory. This is where the whole tree starts.
<u>/dev</u> Contains special files and device files that refer to physical devices.
<u>/home</u> Contains directories for users to store personal files in.
<u>/root</u> The home directory of the root user.`.trimMultiLines()
),
"ls": new Command(
this.ls,
`list directory contents`,
`ls [<b>-a</b> | <b>-A</b> | <b>--all</b>] [<u>directory</u> <u>...</u>]`,
`Displays the files and directories in each <u>directory</u>. 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 <u>directory</u> in order.
Files starting with a <u>.</u> are only shown if the <b>--all</b> option is given, with the \\\
exception of <u>.</u> and <u>..</u>, which are always shown.`.trimMultiLines(),
new InputValidator()
),
"man": new Command(
this.man,
`display manual documentation pages`,
`man <u>page</u> <u>...</u>`,
`Displays the manual pages with names <u>page</u>. Equivalent to using <b>help</b> if at least one \\\
<u>page</u> is given.`.trimMultiLines(),
new InputValidator()
),
"mkdir": new Command(
this.mkdir,
`make directories`,
`mkdir [<b>-p</b> | <b>--parents</b>] <u>directory</u> <u>...</u>`,
`Creates the directories given by <u>directory</u>.
If more than one <u>directory</u> is given, the directories are created in the order they are given \\\
in. If the <b>--parents</b> 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 <u>source</u> <u>destination file</u>
mv <u>source</u> <u>...</u> <u>destination directory</u>`,
`In its first form, <u>source</u> is renamed to <u>target file</u>. <u>target file</u> must not \\\
exist yet.
In its second form, all <u>source</u> files are moved into <u>target directory</u>, which must be a \\\
pre-existing directory. The file names of the <u>source</u> files are retained.`.trimMultiLines(),
new InputValidator({minArgs: 2})
),
"not": new Command(
this.not,
`execute command and invert status code`,
`not <u>command</u>`,
`Executes <u>command</u> 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>-b</b> | <b>--blank</b>] <u>file</u> <u>...</u>`,
`Opens the web pages linked to by <u>file</u>. The first <u>file</u> is opened in this tab and the \\\
subsequent <u>file</u>s are opened in new tabs. If <b>--blank</b> is set, the first <u>file</u> is \\\
opened in a new tab as well.
If this command is executed inside of a standalone app instead of a browser, every <u>file</u> is \\\
opened in a tab regardless of whether <b>--blank</b> is given.`.trimMultiLines(),
new InputValidator({minArgs: 1})
),
"or": new Command(
this.or,
`execute command if previous command failed`,
`or <u>command</u>`,
`Executes <u>command</u> 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 <u>command</u> 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 [<b>-f</b> | <b>--force</b>] [<b>-r</b> | <b>-R</b> | <b>--recursive</b>] \\\
[<b>--no-preserve-root</b>] <u>file</u> <u>...</u>`.trimMultiLines(),
`Removes each given <u>file</u>. If more than one <u>file</u> is given, they are removed in the \\\
order they are given in.
If <b>--force</b> is set, no warning is given if a file could not be removed.
If <b>--recursive</b> is set, files and directories are removed recursively; without this option \\\
directories cannot be removed.
Unless <b>--no-preserve-root</b> is set, the root directory cannot be removed.`.trimMultiLines(),
new InputValidator({minArgs: 1})
),
"rmdir": new Command(
this.rmdir,
`remove directories`,
`rmdir <u>directory</u> <u>...</u>`,
`Removes each given <u>directory</u>. If more than one <u>directory</u> 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 <u>key</u> [<u>value</u>]`,
`Sets the environment variable <u>key</u> to <u>value</u>. If no <u>value</u> 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 <u>file</u> <u>...</u>`,
`Update the access and modification times of each <u>file</u> to the current time. If a <u>file</u> \\\
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 ?? ""}
<b>Usage</b>
@ -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 = "<b>Name</b>\n" + commandName;
if (command.summary !== null)
helpString += "\n\n<b>Summary</b>\n" + command.summary;
if (command.usage !== null)
helpString += "\n\n<b>Usage</b>\n" + command.usage;
if (command.desc !== null)
helpString += "\n\n<b>Description</b>\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 => `<a href="#" onclick="execute('help ${it}')">${it}</a>`)
.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 \\\
<a href="https://git.fwdekker.com/FWDekker/fwdekker.com" ${target}>available on git</a>.
<b>List of commands</b>
${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(`<b>${path}</b>`);
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.
*

View File

@ -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()
}),

617
src/main/js/Scripts.ts Normal file
View File

@ -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 <u>command</u>\`,
\`Executes <u>command</u> 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 <u>command</u> 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 [<b>-e</b> | <b>--escape-html</b>] <u>file</u> <u>...</u>\`,
\`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 <b>--html</b> 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 [<u>directory</u>]\`,
\`Changes the current working directory to <u>directory</u>. If no <u>directory</u> 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 [<b>-r</b> | <b>-R</b> | <b>--recursive</b>] <u>source</u> <u>target file</u>
cp [<b>-r</b> | <b>-R</b> | <b>--recursive</b>] <u>source</u> <u>...</u> <u>target directory</u>\`,
\`In its first form, <u>source</u> is copied to <u>target file</u>. This form is used if there is no file ${n}
or directory at <u>target file</u> beforehand.
In its second form, all <u>source</u> files are copied into <u>target directory</u>, which must be a ${n}
pre-existing directory. The file names of the <u>source</u> files are retained.
In both forms, <u>source</u> files are not copied if they are directories and the <b>-R</b> 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 [<b>-n</b> | <b>--newline</b>] [<u>text</u> <u>...</u>]\`,
\`Displays each <u>text</u> separated by a single whitespace.
Unless the <b>--newline</b> 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:
<u>/</u> This is the root directory. This is where the whole tree starts.
<u>/bin</u> Executable programs fundamental to user environments.
<u>/dev</u> Contains special files and device files that refer to physical devices.
<u>/home</u> Contains directories for users to store personal files in.
<u>/root</u> 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(\`<b>\${path}</b>\`);
streams.out.writeLine(dirList.concat(fileList).join("\\n"));
return 0;
})
.reduce((acc, exitCode) => exitCode === 0 ? acc : exitCode);
},
\`list directory contents\`,
\`ls [<b>-a</b> | <b>-A</b> | <b>--all</b>] [<u>directory</u> <u>...</u>]\`,
\`Displays the files and directories in each <u>directory</u>. 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 <u>directory</u> in order.
Files starting with a <u>.</u> are only shown if the <b>--all</b> option is given, with the exception of ${n}
<u>.</u> and <u>..</u>, 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 <u>page</u> <u>...</u>\`,
\`Displays the manual pages with names <u>page</u>. Equivalent to using <b>help</b> if at least one ${n}
<u>page</u> 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 = "<b>Name</b>\\n" + commandName;
if (command.summary !== null)
helpString += "\\n\\n<b>Summary</b>\\n" + command.summary;
if (command.usage !== null)
helpString += "\\n\\n<b>Usage</b>\\n" + command.usage;
if (command.desc !== null)
helpString += "\\n\\n<b>Description</b>\\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 => \`<a href="#" onclick="execute('help \${it}')">\${it}</a>\`)
.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}
<a href="https://git.fwdekker.com/FWDekker/fwdekker.com" \${target}>available on git</a>.
<b>List of commands</b>
\${commandEntries.join("\\n")}
Write "help [COMMAND]" or click a command in the list above for more information.\`.trimMultiLines()
);
return 0;
}
},
\`display documentation\`,
\`help [<u>command</u> <u>...</u>]\`,
\`Displays help documentation for each <u>command</u>.
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 [<b>-p</b> | <b>--parents</b>] <u>directory</u> <u>...</u>\`,
\`Creates the directories given by <u>directory</u>.
If more than one <u>directory</u> is given, the directories are created in the order they are given in. If ${n}
the <b>--parents</b> 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 <u>source</u> <u>destination file</u>
mv <u>source</u> <u>...</u> <u>destination directory</u>\`,
\`In its first form, <u>source</u> is renamed to <u>target file</u>. <u>target file</u> must not exist yet.
In its second form, all <u>source</u> files are moved into <u>target directory</u>, which must be a ${n}
pre-existing directory. The file names of the <u>source</u> 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 <u>command</u>\`,
\`Executes <u>command</u> 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>-b</b> | <b>--blank</b>] <u>file</u> <u>...</u>\`,
\`Opens the web pages linked to by <u>file</u>. The first <u>file</u> is opened in this tab and the ${n}
subsequent <u>file</u>s are opened in new tabs. If <b>--blank</b> is set, the first <u>file</u> is opened ${n}
in a new tab as well.
If this command is executed inside of a standalone app instead of a browser, every <u>file</u> is opened in ${n}
a tab regardless of whether <b>--blank</b> 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 <u>command</u>\`,
\`Executes <u>command</u> 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 <u>command</u> 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 [<b>-f</b> | <b>--force</b>] [<b>-r</b> | <b>-R</b> | <b>--recursive</b>] ${n}
[<b>--no-preserve-root</b>] <u>file</u> <u>...</u>\`.trimMultiLines(),
\`Removes each given <u>file</u>. If more than one <u>file</u> is given, they are removed in the order they ${n}
are given in.
If <b>--force</b> is set, no warning is given if a file could not be removed.
If <b>--recursive</b> is set, files and directories are removed recursively; without this option ${n}
directories cannot be removed.
Unless <b>--no-preserve-root</b> 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 <u>directory</u> <u>...</u>\`,
\`Removes each given <u>directory</u>. If more than one <u>directory</u> 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 <u>key</u> [<u>value</u>]\`,
\`Sets the environment variable <u>key</u> to <u>value</u>. If no <u>value</u> 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 <u>file</u> <u>...</u>\`,
\`Update the access and modification times of each <u>file</u> to the current time. If a <u>file</u> 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})
)`),
});

View File

@ -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;
/**

View File

@ -1,5 +1,5 @@
/**
* Manages a user session.
* Manages a list of users.
*/
export class UserList {
/**