forked from tools/josh
1
0
Fork 0
josh/src/main/js/Commands.ts

1211 lines
47 KiB
TypeScript

import {Environment} from "./Environment";
import "./Extensions";
import {Directory, File, FileSystem, Node, Path,} from "./FileSystem";
import {InputArgs} from "./InputArgs";
import {InputParser} from "./InputParser";
import {Persistence} from "./Persistence";
import {escapeHtml, IllegalArgumentError, IllegalStateError, isStandalone} from "./Shared";
import {OutputStream, StreamSet} from "./Stream";
import {EscapeCharacters} from "./Terminal";
import {HashProvider, User, UserList} from "./UserList";
/**
* A collection of commands that can be executed.
*/
export class Commands {
/**
* The environment in which commands are executed.
*/
private readonly environment: Environment;
/**
* The user list describing the available users.
*/
private readonly userList: UserList;
/**
* The file system to interact with.
*/
private readonly fileSystem: FileSystem;
/**
* Constructs a new collection of commands.
*
* @param environment the environment in which commands are executed
* @param userList the user list describing the user that executes commands
* @param fileSystem the file system to interact with
*/
constructor(environment: Environment, userList: UserList, fileSystem: FileSystem) {
this.environment = environment;
this.userList = userList;
this.fileSystem = fileSystem;
}
/**
* Parses and executes the given input string and returns the exit code of that command.
*
* @param input the input string to parse and execute
* @param streams the streams to interact with
*/
execute(input: InputArgs, streams: StreamSet): number {
if (input.command === "")
return ExitCode.OK;
const localStreams = streams.copy();
try {
localStreams.out = this.toStream(input.redirectTargets[1]) ?? localStreams.out;
localStreams.err = this.toStream(input.redirectTargets[2]) ?? localStreams.err;
} catch (error) {
if (!(error instanceof Error))
throw Error(`Error while processing redirection error:\n${error}`);
streams.err.writeLine(`Error while redirecting output:\n${error.message}`);
return ExitCode.MISC;
}
const target = this.resolve(input.command);
if (target === undefined) {
localStreams.err.writeLine(`Unknown command '${input.command}'.`);
return ExitCode.COMMAND_NOT_FOUND;
}
if (target instanceof Error) {
localStreams.err.writeLine(`Could not parse command '${input.command}': ${target}.`);
return ExitCode.COMMAND_NOT_FOUND;
}
if (target instanceof DocOnlyCommand) {
localStreams.err.writeLine(`Could not execute doc-only command. Try 'help ${input.command}' instead.`);
return ExitCode.COMMAND_NOT_FOUND;
}
if (target instanceof Directory) {
return this.execute(
new InputArgs("/bin/cd", input.options, [input.command].concat(input.args), input.redirectTargets),
localStreams
);
} else if (target instanceof Script) {
const parser = InputParser.create(this.environment, this.fileSystem);
return target.lines
.map(line => parser.parseCommands(line))
.reduce((acc, input) => acc.concat(input), []) // .flat()
.reduce((acc, code) => acc !== 0 ? acc : this.execute(code, localStreams), 0);
} else {
const validation = target.validator.validate(input);
if (!validation[0]) {
localStreams.err.writeLine(this.createUsageErrorOutput(input.command, target, validation[1]));
return ExitCode.USAGE;
}
return target.fun.bind(this)(input, localStreams);
}
}
/**
* Resolves a target from its name by looking through the file system.
*
* If the target is a command, the command is parsed (but not yet invoked) and returned.
*
* Targets are resolved as follows:
* If the target name contains a slash, then resolve target relatively and return the result.
* Otherwise, look for the target in /bin. If no such target exists or it is not a file, then resolve the target
* relatively but return `undefined` if it is not a directory.
*
* @param targetName the name of the target to be resolved
* @return the `Command`, `Script`, or `Directory` addressed by the target, an `Error` if the target could be found
* but could not be parsed, or `undefined` if the target could not be found
*/
resolve(targetName: string): Command | Script | Directory | Error | undefined {
const cwd = this.environment.get("cwd");
let target: Node | undefined;
if (targetName.includes("/")) {
target = this.fileSystem.get(Path.interpret(cwd, targetName));
if (!(target instanceof File))
return target instanceof Directory ? target : undefined;
} else {
target = this.fileSystem.get(Path.interpret(cwd, "/bin", targetName));
if (!(target instanceof File)) {
target = this.fileSystem.get(Path.interpret(cwd, targetName));
return target instanceof Directory ? target : undefined;
}
}
const code = target.open("read").read();
try {
if (code.startsWith("#!/bin/josh\n")) {
return new Script(code.split("\n").slice(1));
} else {
return this.interpretBinary(code, this.environment, this.userList, this.fileSystem);
}
} catch (error) {
if (!(error instanceof Error))
throw Error(`Error while processing interpretation error:\n${error}`);
console.error(`Failed to interpret script '${targetName}'.`, code, error);
return error;
}
}
/**
* Interprets the given binary and returns the `Command` it describes.
*
* @param code a string describing a `Command`, i.e. a "binary"
* @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 interpretBinary(code: string, environment: Environment, userList: UserList,
fileSystem: FileSystem): Command {
const josh = {
"environment": environment,
"fileSystem": fileSystem,
"interpreter": this,
"userList": userList,
"util": {
"escapeHtml": escapeHtml,
"isStandalone": isStandalone
}
};
const namespace = {
"Command": Command,
"Directory": Directory,
"DocOnlyCommand": DocOnlyCommand,
"EscapeCharacters": EscapeCharacters,
"ExitCode": ExitCode,
"File": File,
"HashProvider": HashProvider,
"InputParser": InputParser,
"InputValidator": InputValidator,
"Path": Path,
"Persistence": Persistence,
"User": User,
"josh": josh
};
return Function(...(Object.keys(namespace).concat([code])))(...Object.values(namespace));
};
/**
* 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, command: Command, errorMessage: string | undefined): string {
return `Invalid usage of '${commandName}'. ${errorMessage ?? ""}
<b>Usage</b>
${command.usage}`.trimLines();
}
/**
* Converts a redirect target to an output stream, or `undefined` if the default stream is used.
*
* @param target the target to convert
* @throws if the stream could not be opened
*/
private toStream(target: InputArgs.RedirectTarget | undefined): OutputStream | undefined {
if (target === undefined)
return undefined;
if (target.target === undefined)
throw new IllegalStateError("Redirect target's target is undefined.");
return this.fileSystem.open(Path.interpret(this.environment.get("cwd"), target.target), target.type);
}
}
/**
* A script referring to other commands that can be executed.
*/
export class Script {
/**
* The lines that make up the script.
*/
readonly lines: string[];
/**
* Constructs a new script from the given lines.
*
* Each line should be a valid line in the command line, i.e. can be parsed into an `InputArgs`.
*
* @param lines the lines that make up the script
*/
constructor(lines: string[]) {
this.lines = lines;
}
}
/**
* A command that can be executed.
*/
export class Command {
/**
* The function to execute with the command is executed.
*/
readonly fun: (args: InputArgs, streams: StreamSet) => number;
/**
* A short summary of what the command does.
*/
readonly summary: string | null;
/**
* A string describing how the command is to be used.
*/
readonly usage: string | null;
/**
* A longer description of what the command does and how its parameters work.
*/
readonly desc: string | null;
/**
* A function that validates input for this command.
*/
readonly validator: InputValidator;
/**
* Constructs a new command.
*
* @param fun the function to execute with the command is executed
* @param summary a short summary of what the command does, or `null` if not applicable
* @param usage a string describing how the command is to be used, or `null` if not applicable
* @param desc a longer description of what the command does and how its parameters work, or `null` if not
* applicable
* @param validator a function that validates input for this command
*/
constructor(fun: (args: InputArgs, streams: StreamSet) => number, summary: string | null, usage: string | null,
desc: string | null, validator: InputValidator) {
this.fun = fun;
this.summary = summary;
this.usage = usage;
this.desc = desc;
this.validator = validator;
}
}
/**
* A command that cannot be executed, but of which the documentation can be looked up anyway.
*/
export class DocOnlyCommand extends Command {
/**
* Constructs a new doc-only command.
*
* @param summary a short summary of what the command does, or `null` if not applicable
* @param desc a longer description of what the command does and how its parameters work, or `null` if not
* applicable
*/
constructor(summary: string | null, desc: string | null) {
super(() => {
throw new IllegalStateError("Cannot execute doc-only command.");
}, summary, null, desc, new InputValidator());
}
}
/**
* Validates the input of a command.
*/
export class InputValidator {
/**
* The minimum number of arguments allowed.
*/
readonly minArgs: number;
/**
* The maximum number of arguments allowed.
*/
readonly maxArgs: number;
/**
* Constructs a new input validator.
*
* @param minArgs the minimum number of arguments allowed
* @param maxArgs the maximum number of arguments allowed
*/
constructor({minArgs = 0, maxArgs = Number.MAX_SAFE_INTEGER}: { minArgs?: number, maxArgs?: number } = {}) {
if (minArgs < 0)
throw new IllegalArgumentError("'minArgs' must be non-negative.");
if (maxArgs < 0)
throw new IllegalArgumentError("'maxArgs' must be non-negative.");
if (minArgs > maxArgs)
throw new IllegalArgumentError("'minArgs' must be less than or equal to 'maxArgs'.");
this.minArgs = minArgs;
this.maxArgs = maxArgs;
}
/**
* Returns `[true]` if the input is valid, or `[false, string]` where the string is a reason if the input is not
* valid.
*
* @param input the input to validate
*/
validate(input: InputArgs): [true] | [false, string] {
if (this.minArgs === this.maxArgs && input.argc !== this.minArgs)
return [false, `Expected ${this.argString(this.minArgs)} but got ${input.argc}.`];
if (input.argc < this.minArgs)
return [false, `Expected at least ${this.argString(this.minArgs)} but got ${input.argc}.`];
if (input.argc > this.maxArgs)
return [false, `Expected at most ${this.argString(this.maxArgs)} but got ${input.argc}.`];
return [true];
}
/**
* Returns `"1 argument"` if the given amount is `1` and returns `"$n arguments"` otherwise.
*
* @param amount the amount to check
*/
private argString(amount: number): string {
return amount === 1 ? `1 argument` : `${amount} arguments`;
}
}
/**
* Standard exit codes.
*
* Inspired by `/usr/include/sysexits.h` as seen in FreeBSD 12.1.
*/
export enum ExitCode {
/**
* Successful termination.
*/
OK = 0,
/**
* Some unspecified error.
*/
MISC = 1,
/**
* Command line usage error.
*/
USAGE = 64,
/**
* Input data was incorrect in one way or another.
*/
DATA_ERROR = 65,
/**
* An input file could not be found.
*/
FILE_NOT_FOUND = 66,
/**
* An output file could not be created.
*/
CANT_CREATE = 73,
/**
* Some I/O operation failed on a file.
*/
IO_ERROR = 74,
/**
* The failure is temporary, and the user should probably try again.
*/
TEMP_FAIL = 75,
/**
* Not enough permissions to execute the desired action.
*/
PERMISSION = 77,
/**
* Something was not configured correctly.
*/
CONFIG = 78,
/**
* The desired command could not be found.
*/
COMMAND_NOT_FOUND = 127
}
// An escaped newline escape symbol
const n = "".trimMultiLinesSep + "".trimMultiLinesSep;
/**
* Returns the script contents of the binaries in the `/bin` directory.
*
* @return the script contents of the binaries in the `/bin` directory
*/
// @formatter:off
export const commandBinaries: { [key: string]: string } = {
"and": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
const previousStatus = Number(josh.environment.getOrDefault("status", ExitCode.OK.toString()));
if (previousStatus !== ExitCode.OK)
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 ${ExitCode.OK}.
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": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
return input.args
.map(arg => Path.interpret(josh.environment.get("cwd"), arg))
.map(path => {
const node = josh.fileSystem.get(path);
if (!(node instanceof File)) {
streams.err.writeLine(\`cat: '\${path}': No such file.\`);
return ExitCode.FILE_NOT_FOUND;
}
let contents = node.open("read").read();
if (input.hasAnyOption("-e", "--escape-html"))
contents = josh.util.escapeHtml(contents);
if (!contents.endsWith("\\n"))
contents += "\\n";
streams.out.write(contents);
return ExitCode.OK;
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? 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>--escape-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": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
if (input.argc === 0) {
josh.environment.set("cwd", josh.environment.get("home"));
return ExitCode.OK;
}
const path = Path.interpret(josh.environment.get("cwd"), input.args[0]);
const target = josh.fileSystem.get(path);
if (target === undefined) {
streams.err.writeLine(\`cd: The directory '\${path}' does not exist.\`);
return ExitCode.FILE_NOT_FOUND;
} else if (!(target instanceof Directory)) {
streams.err.writeLine(\`cd: '\${path}' is not a directory.\`);
return ExitCode.USAGE;
}
josh.environment.set("cwd", path.toString());
return ExitCode.OK;
},
\`change directory\`,
\`cd [<u>directory</u>]\`,
\`Changes the current working directory to <u>directory</u>. If no <u>directory</u> is supplied, the current ${n}
working directory is changed to the current user's home directory.\`.trimMultiLines(),
new InputValidator({maxArgs: 1})
)`,
"clear": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
streams.out.write(EscapeCharacters.Escape + EscapeCharacters.Clear);
return ExitCode.OK;
},
\`clear terminal output\`,
\`clear\`,
\`Clears all previous terminal output.\`,
new InputValidator({maxArgs: 0})
)`,
"cp": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
let mappings;
try {
const cwd = josh.environment.get("cwd");
mappings = josh.fileSystem.determineMoveMappings(
input.args.slice(0, -1).map((it) => Path.interpret(cwd, it)),
Path.interpret(cwd, input.args.slice(-1)[0])
);
} catch (error) {
streams.err.writeLine(\`cp: \${error.message}\`);
return ExitCode.MISC;
}
return mappings
.map(([source, destination]) => {
try {
josh.fileSystem.copy(source, destination, input.hasAnyOption("-r", "-R", "--recursive"));
return ExitCode.OK;
} catch (error) {
streams.err.writeLine(\`cp: \${error.message}\`);
return ExitCode.MISC;
}
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? 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>\`.trimMultiLines(),
\`In its first form, <u>source</u> is copied to <u>target-file</u>. This form is used if there is no file or ${n}
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 given.${n}
\`.trimMultiLines(),
new InputValidator({minArgs: 2})
)`,
"echo": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
let message = input.args.join(" ").replace("hunter2", "*******");
if (input.hasAnyOption("-e", "--escapes"))
message = message.replace(/\\\\n/g, "\\n");
if (!input.hasAnyOption("-n", "--newline"))
message = message + "\\n";
streams.out.write(message);
return ExitCode.OK;
},
\`display text\`,
\`echo [<b>-e</b> | <b>--escapes</b>] [<b>-n</b> | <b>--newline</b>] [<u>text</u> <u>...</u>]\`,
\`Displays each <u>text</u> separated by a single whitespace.
If the <b>--escapes</b> parameter is given, the newline escape sequence "\\\\n" is replaced by an actual newline
character.
Unless the <b>--newline</b> parameter is given, a newline is appended to the end.\`.trimMultiLines(),
new InputValidator()
)`,
"exit": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
josh.environment.set("user", "");
return parseInt(input.args[0] || "0");
},
\`close session\`,
\`exit [<u>status</u>]\`,
\`Closes the terminal session.
Returns status code <u>status</u> if it is defined, and returns 0 otherwise.\`,
new InputValidator({minArgs: 0, maxArgs: 1})
)`,
"false": /* language=JavaScript */ `\
return new Command(
() => {return ExitCode.MISC;},
\`return unsuccessful exit code\`,
\`false\`,
\`Set the <tt>status</tt> environment variable to ${ExitCode.MISC}.\`.trimMultiLines(),
new InputValidator({minArgs: 0})
)`,
"help": /* language=JavaScript */ `\
return 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 || command instanceof Directory) {
streams.err.writeLine(\`help: Unknown command '\${commandName}'.\`);
return ExitCode.USAGE;
}
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 ExitCode.OK;
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? acc : exitCode);
} else {
const cwd = josh.environment.get("cwd");
const slashBin = josh.fileSystem.get(Path.interpret(cwd, "/bin"));
if (!(slashBin instanceof Directory)) {
return ExitCode.FILE_NOT_FOUND;
}
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 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/tools/josh" \${target}>available on git</a>. ${n}
This website also has a <a href="https://fwdekker.com/privacy/">privacy policy</a> to assure you I won't
steal your data.
<b>List of commands</b>
\${commandEntries.join("\\n")}
Write "help [COMMAND]" or click a command in the list above for more information.\`.trimMultiLines()
);
return ExitCode.OK;
}
},
\`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": /* language=JavaScript */ `\
return new DocOnlyCommand(
\`description of the file system 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>/etc</u> System configuration files and scripts.
<u>/home</u> Contains directories for users to store personal files in.
<u>/root</u> The home directory of the root user.\`.trimMultiLines()
)`,
"ls": /* language=JavaScript */ `\
return 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 ExitCode.FILE_NOT_FOUND;
}
if (!(node instanceof Directory)) {
streams.err.writeLine(\`ls: '\${path}' is not a directory.\`);
return ExitCode.USAGE;
}
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 (name.startsWith(".") && !input.hasAnyOption("-a", "-A", "--all"))
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(input.hasAnyOption("-l", "-L", "--long") ? "\\n" : " "));
return ExitCode.OK;
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? acc : exitCode);
},
\`list directory contents\`,
\`ls [<b>-a</b> | <b>-A</b> | <b>--all</b>] [<b>-l</b> <b>-L</b> <b>--long</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.
Files and directories are separated by a whitespace by default. With the <b>--long</b> option, the separator is ${n}
is changed to the newline character.\`.trimMultiLines(),
new InputValidator()
)`,
"mkdir": /* language=JavaScript */ `\
return 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 ExitCode.OK;
} catch (error) {
streams.err.writeLine(\`mkdir: \${error.message}\`);
return ExitCode.MISC;
}
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? 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 the ${n}
<b>--parents</b> option is given, parent directories that do not exist are created as well.\`.trimMultiLines(),
new InputValidator({minArgs: 1})
)`,
"mv": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
let mappings;
try {
const cwd = josh.environment.get("cwd");
mappings = josh.fileSystem.determineMoveMappings(
input.args.slice(0, -1).map((it) => Path.interpret(cwd, it)),
Path.interpret(cwd, input.args.slice(-1)[0])
);
} catch (error) {
streams.err.writeLine(\`mv: \${error.message}\`);
return ExitCode.MISC;
}
return mappings
.map(([source, destination]) => {
try {
josh.fileSystem.move(source, destination);
return ExitCode.OK;
} catch (error) {
streams.err.writeLine(\`mv: \${error.message}\`);
return ExitCode.MISC;
}
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? acc : exitCode);
},
\`move files\`,
\`mv <u>source</u> <u>destination-file</u>
mv <u>source</u> <u>...</u> <u>destination-directory</u>\`.trimMultiLines(),
\`In its first form, <u>source</u> is renamed to <u>destination-file</u>. <u>destination-file</u> must not ${n}
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": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
const exitCode = josh.interpreter.execute(
InputParser.create(josh.environment, josh.fileSystem).parseCommand(input.args),
streams
);
return exitCode === ExitCode.OK ? ExitCode.MISC : ExitCode.OK;
},
\`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, ${n}
the exit code is set to ${ExitCode.OK} if it was non-zero, and is set to ${ExitCode.MISC} otherwise.${n}
\`.trimMultiLines(),
new InputValidator({minArgs: 1})
)`,
"open": /* language=JavaScript */ `\
return 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 ExitCode.OK;
} catch (error) {
streams.err.writeLine(\`open: \${error.message}\`);
return ExitCode.MISC;
}
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? 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 subsequent ${n}
<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 ${n}
well.
If this command is executed inside of a standalone app instead of a browser, every <u>file</u> is opened in a ${n}
tab regardless of whether <b>--blank</b> is given.\`.trimMultiLines(),
new InputValidator({minArgs: 1})
)`,
"or": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
const previousStatus = Number(josh.environment.getOrDefault("status", "" +ExitCode.OK));
if (previousStatus === ExitCode.OK)
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 ${ExitCode.OK}.
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": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
const userName = josh.environment.get("user");
if (userName === "") {
streams.err.writeLine("poweroff: Cannot execute while not logged in.");
return ExitCode.MISC;
}
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 ExitCode.OK;
},
\`close down the system\`,
\`poweroff\`,
\`Automated shutdown procedure to nicely notify users when the system is shutting down.\`,
new InputValidator({maxArgs: 0})
)`,
"pwd": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
streams.out.writeLine(josh.environment.get("cwd") || "");
return ExitCode.OK;
},
\`print working directory\`,
\`pwd\`,
\`Displays the current working directory.\`,
new InputValidator({maxArgs: 0})
)`,
"rm": /* language=JavaScript */ `\
return 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 ExitCode.OK;
streams.err.writeLine(\`rm: The file '\${path}' does not exist.\`);
return ExitCode.FILE_NOT_FOUND;
}
if (target instanceof Directory) {
if (!input.hasAnyOption("-r", "-R", "--recursive")) {
streams.err.writeLine(\`rm: '\${path}' is a directory.\`);
return ExitCode.USAGE;
}
if (path.toString() === "/" && !input.hasAnyOption("--no-preserve-root")) {
streams.err.writeLine("rm: Cannot remove root directory.");
return ExitCode.USAGE;
}
}
josh.fileSystem.remove(path);
return ExitCode.OK;
} catch (error) {
streams.err.writeLine(\`rm: \${error.message}\`);
return ExitCode.MISC;
}
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? 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 are ${n}
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 ${n}
cannot be removed.
Unless <b>--no-preserve-root</b> is set, the root directory cannot be removed.\`.trimMultiLines(),
new InputValidator({minArgs: 1})
)`,
"rmdir": /* language=JavaScript */ `\
return 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 ExitCode.FILE_NOT_FOUND;
}
if (!(target instanceof Directory)) {
streams.err.writeLine(\`rmdir: '\${path}' is not a directory.\`);
return ExitCode.USAGE;
}
if (target.nodeCount !== 0) {
streams.err.writeLine(\`rmdir: '\${path}' is not empty.\`);
return ExitCode.MISC;
}
josh.fileSystem.remove(path);
return ExitCode.OK;
} catch (error) {
streams.err.writeLine(\`rmdir: \${error.message}\`);
return ExitCode.MISC;
}
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? 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": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
try {
if (input.argc === 0) {
Object.keys(josh.environment.variables)
.sort()
.forEach((key) => streams.out.writeLine(\`\${key} \${josh.environment.variables[key]}\`));
} else if (input.argc === 1) {
josh.environment.safeDelete(input.args[0]);
} else {
josh.environment.safeSet(input.args[0], input.args[1]);
}
return ExitCode.OK;
} catch (error) {
streams.err.writeLine(\`set: \${error.message}\`);
return ExitCode.MISC;
}
},
\`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 or cleared.
If neither <u>key</u> nor <u>value</u> is given, a list of all environment variables with current values is given.
\`.trimMultiLines(),
new InputValidator({minArgs: 0, maxArgs: 2})
)`,
"touch": /* language=JavaScript */ `\
return 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 ExitCode.OK;
} catch (error) {
streams.err.writeLine(\`touch: \${error.message}\`);
return ExitCode.MISC;
}
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? 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 not ${n}
exist, it is created.\`.trimMultiLines(),
new InputValidator({minArgs: 1})
)`,
"true": /* language=JavaScript */ `\
return new Command(
() => {return ExitCode.OK;},
\`return successful exit code\`,
\`true\`,
\`Set the <tt>status</tt> environment variable to ${ExitCode.OK}.\`.trimMultiLines(),
new InputValidator({minArgs: 0})
)`,
"useradd": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
if (josh.userList.has(input.args[0])) {
streams.err.writeLine(\`useradd: User '\${input.args[0]}' already exists.\`);
return ExitCode.MISC;
}
let user;
try {
user = new User(input.args[0], HashProvider.default.hashPassword(input.args[1]));
if (input.hasAnyOption("-h", "--home"))
user.home = input.options["-h"] || input.options["--home"];
if (input.hasAnyOption("-d", "--description"))
user.description = input.options["-d"] || input.options["--description"];
} catch (error) {
streams.err.writeLine(\`useradd: \${error.message}\`);
return ExitCode.MISC;
}
if (!josh.userList.add(user)) {
streams.err.writeLine(\`useradd: Unexpectedly failed to add user '\${input.args[0]}'.\`);
return ExitCode.MISC;
}
streams.out.writeLine(\`useradd: Added user '\${input.args[0]}'.\`);
return ExitCode.OK;
},
\`add new user\`,
\`useradd ${n}
[<b>-h</b>/<b>--home</b>=<u>home</u>] ${n}
[<b>-d</b>/<b>--description</b>=<u>description</u>] ${n}
<u>name</u> <u>password</u>\`.trimMultiLines(),
\`Adds a user with the given data to the system.
The <u>name</u> must consist solely of alphanumerical characters.
The <u>home</u> directory and the <u>description</u> must not contain the pipe character ('|') or the newline ${n}
character ('\\\\n').
If no <u>home</u> is given, it defaults to "/home/<u>name</u>".\`.trimMultiLines(),
new InputValidator({minArgs: 2, maxArgs: 4})
)`,
"userdel": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
if (!josh.userList.has(input.args[0])) {
streams.err.writeLine(\`userdel: Could not delete non-existent user '\${input.args[0]}'.\`);
return ExitCode.USAGE;
}
if (!josh.userList.delete(input.args[0])) {
streams.err.writeLine(\`userdel: Unexpectedly failed to delete user '\${input.args[0]}'.\`);
return ExitCode.MISC;
}
streams.out.writeLine(\`userdel: Deleted user '\${input.args[0]}'.\`);
return ExitCode.OK;
},
\`delete user\`,
\`userdel <u>name</u>\`,
\`Deletes the user with the given <u>name</u>.\`.trimMultiLines(),
new InputValidator({minArgs: 1, maxArgs: 1})
)`,
"usermod": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
let user = josh.userList.get(input.args[0]);
if (user === undefined) {
streams.err.writeLine(\`usermod: Could not modify non-existent user '\${input.args[0]}'.\`);
return ExitCode.USAGE;
}
try {
if (input.hasAnyOption("-p", "--password")) {
const password = input.options["-p"] || input.options["--password"];
user.passwordHash = HashProvider.default.hashPassword(password);
}
if (input.hasAnyOption("-h", "--home"))
user.home = input.options["-h"] || input.options["--home"];
if (input.hasAnyOption("-d", "--description"))
user.description = input.options["-d"] || input.options["--description"];
} catch (error) {
streams.err.writeLine(\`usermod: \${error.message}\`);
return ExitCode.MISC;
}
if (!josh.userList.modify(user)) {
streams.err.writeLine(\`usermod: Unexpectedly failed to modify user '\${input.args[0]}'.\`);
return ExitCode.MISC;
}
streams.out.writeLine(\`usermod: Modified user '\${input.args[0]}'.\`);
return ExitCode.OK;
},
\'modify user\',
\`usermod ${n}
[<b>-p</b>/<b>--password</b>=<u>password</u>] ${n}
[<b>-h</b>/<b>--home</b>=<u>home</u>] ${n}
[<b>-d</b>/<b>--description</b>=<u>description</u>] ${n}
<u>name</u>\`.trimMultiLines(),
\`Modifies the user with the given <u>name</u>. See the "useradd" command for more information on the fields ${n}
that can be modified.\`.trimMultiLines(),
new InputValidator({minArgs: 1, maxArgs: 1})
)`,
"whatis": /* language=JavaScript */ `\
return new Command(
(input, streams) => {
return input.args
.map(commandName => {
const command = josh.interpreter.resolve(commandName);
if (command === undefined) {
streams.err.writeLine(\`whatis: Unknown command '\${commandName}'.\`);
return ExitCode.USAGE;
}
streams.out.writeLine("<b>" + commandName + "</b> - " + command.summary);
return ExitCode.OK;
})
.reduce((acc, exitCode) => exitCode === ExitCode.OK ? acc : exitCode);
},
\`display one-line documentation\`,
\`whatis <u>command</u> <u>...</u>\`,
\`Displays a one-line summary for each <u>command</u>.\`.trimMultiLines(),
new InputValidator({minArgs: 1})
)`,
"whoami": /* language=JavaScript */ `\
return 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 ExitCode.MISC;
}
streams.out.writeLine(user.description);
return ExitCode.OK;
},
\`print short description of user\`,
\`whoami\`,
\`Print a description of the user associated with the current effective user ID.\`,
new InputValidator({maxArgs: 0})
)`,
};
// @formatter:on