From cf3cc5dd0bf01d91792e1a42e97554ecd42336ef Mon Sep 17 00:00:00 2001 From: akida31 Date: Thu, 27 May 2021 16:05:17 +0200 Subject: [PATCH] multiple shell improvements refactored first commands added autocomplete after first word (cryptic-game#39) added some argparsing improved help command added aliases --- .../desktop/windows/terminal/terminal-api.ts | 2 +- .../windows/terminal/terminal-states.ts | 84 +++++++++- .../windows/terminal/terminal.component.ts | 33 ++-- src/app/shell/builtins/builtins.ts | 10 ++ src/app/shell/builtins/cd.ts | 43 +++++ src/app/shell/builtins/clear.ts | 14 ++ src/app/shell/builtins/exit.ts | 14 ++ src/app/shell/builtins/hostname.ts | 51 ++++++ src/app/shell/builtins/ls.ts | 54 +++++++ src/app/shell/builtins/miner.ts | 142 ++++++++++++++++ src/app/shell/builtins/status.ts | 17 ++ src/app/shell/builtins/template.ts | 13 ++ src/app/shell/command.ts | 151 ++++++++++++++++++ src/app/shell/shell.ts | 143 +++++++++++++++++ src/app/shell/shellapi.ts | 28 ++++ src/app/websocket.service.ts | 4 + 16 files changed, 782 insertions(+), 21 deletions(-) create mode 100644 src/app/shell/builtins/builtins.ts create mode 100644 src/app/shell/builtins/cd.ts create mode 100644 src/app/shell/builtins/clear.ts create mode 100644 src/app/shell/builtins/exit.ts create mode 100644 src/app/shell/builtins/hostname.ts create mode 100644 src/app/shell/builtins/ls.ts create mode 100644 src/app/shell/builtins/miner.ts create mode 100644 src/app/shell/builtins/status.ts create mode 100644 src/app/shell/builtins/template.ts create mode 100644 src/app/shell/command.ts create mode 100644 src/app/shell/shell.ts create mode 100644 src/app/shell/shellapi.ts diff --git a/src/app/desktop/windows/terminal/terminal-api.ts b/src/app/desktop/windows/terminal/terminal-api.ts index d4069cb1..eae8cc08 100644 --- a/src/app/desktop/windows/terminal/terminal-api.ts +++ b/src/app/desktop/windows/terminal/terminal-api.ts @@ -46,7 +46,7 @@ export interface TerminalAPI { export interface TerminalState { execute(command: string); - autocomplete(content: string): string; + autocomplete(content: string): Promise; getHistory(): string[]; diff --git a/src/app/desktop/windows/terminal/terminal-states.ts b/src/app/desktop/windows/terminal/terminal-states.ts index 9794f96d..1a909a3f 100644 --- a/src/app/desktop/windows/terminal/terminal-states.ts +++ b/src/app/desktop/windows/terminal/terminal-states.ts @@ -10,6 +10,8 @@ import {of} from 'rxjs'; import {Device} from '../../../api/devices/device'; import {WindowDelegate} from '../../window/window-delegate'; import {File} from '../../../api/files/file'; +import {Shell} from 'src/app/shell/shell'; +import {ShellApi} from 'src/app/shell/shellapi'; function escapeHtml(html: string): string { @@ -33,7 +35,7 @@ export abstract class CommandTerminalState implements TerminalState { executeCommand(command: string, args: string[], io: IOHandler = null) { const iohandler = io ? io : { stdout: this.stdoutHandler.bind(this), - stdin: this.stdinHandler, + stdin: this.stdinHandler.bind(this), stderr: this.stderrHandler.bind(this), args: args }; @@ -170,7 +172,7 @@ export abstract class CommandTerminalState implements TerminalState { abstract commandNotFound(command: string, iohandler: IOHandler): void; - autocomplete(content: string): string { + async autocomplete(content: string): Promise { return content ? Object.entries(this.commands) .filter(command => !command[1].hidden) @@ -312,6 +314,10 @@ export class DefaultTerminalState extends CommandTerminalState { executor: this.read.bind(this), description: 'read input of user' }, + 'msh': { + executor: this.msh.bind(this), + description: 'create a new shell' + }, // easter egg 'chaozz': { @@ -1947,6 +1953,16 @@ export class DefaultTerminalState extends CommandTerminalState { this.setExitCode(0); }); } + + msh(_: IOHandler) { + this.terminal.pushState( + new ShellTerminal( + this.websocket, this.settings, this.fileService, + this.domSanitizer, this.windowDelegate, this.activeDevice, + this.terminal, this.promptColor + ) + ); + } } @@ -1972,7 +1988,7 @@ export abstract class ChoiceTerminalState implements TerminalState { this.terminal.outputText('\'' + choice + '\' is not one of the following: ' + Object.keys(this.choices).join(', ')); } - autocomplete(content: string): string { + async autocomplete(content: string): Promise { return content ? Object.keys(this.choices).sort().find(choice => choice.startsWith(content)) : ''; } @@ -2060,7 +2076,7 @@ class DefaultStdin implements TerminalState { this.callback(input); } - autocomplete(_: string): string { + async autocomplete(_: string): Promise { return ''; } @@ -2144,3 +2160,63 @@ enum OutputType { TEXT, NODE, } + +class ShellTerminal implements TerminalState { + private shell: Shell; + + constructor(private websocket: WebsocketService, private settings: SettingsService, private fileService: FileService, + private domSanitizer: DomSanitizer, windowDelegate: WindowDelegate, private activeDevice: Device, + private terminal: TerminalAPI, private promptColor: string = null + ) { + const shellApi = new ShellApi( + this.websocket, this.settings, this.fileService, + this.domSanitizer, windowDelegate, this.activeDevice, + terminal, this.promptColor, this.refreshPrompt.bind(this), + Path.ROOT + ); + this.shell = new Shell( + this.terminal.output.bind(this.terminal), + // TODO use this + // this.terminal.outputText.bind(this.terminal), + this.stdinHandler.bind(this), + this.terminal.outputText.bind(this.terminal), + shellApi, + ); + } + + /** default implementaion for stdin: reading from console */ + stdinHandler(callback: (input: string) => void) { + return new DefaultStdin(this.terminal).read(callback); + } + + execute(command: string) { + this.shell.execute(command); + } + + async autocomplete(content: string): Promise { + return await this.shell.autocomplete(content); + } + + getHistory(): string[] { + return this.shell.getHistory(); + } + + refreshPrompt() { + this.fileService.getAbsolutePath(this.activeDevice['uuid'], this.shell.api.working_dir).subscribe(path => { + // const color = this.domSanitizer.sanitize(SecurityContext.STYLE, this.promptColor || this.settings.getTPC()); + // TODO undo this + const color = 'yellow'; + const prompt = this.domSanitizer.bypassSecurityTrustHtml( + `` + + `${escapeHtml(this.websocket.account.name)}@${escapeHtml(this.activeDevice['name'])}` + + `:` + + `/${path.join('/')}$` + + `` + ); + this.terminal.changePrompt(prompt); + }); + + } + +} + diff --git a/src/app/desktop/windows/terminal/terminal.component.ts b/src/app/desktop/windows/terminal/terminal.component.ts index 978dcf05..bf9094de 100644 --- a/src/app/desktop/windows/terminal/terminal.component.ts +++ b/src/app/desktop/windows/terminal/terminal.component.ts @@ -1,12 +1,12 @@ -import { SettingsService } from '../settings/settings.service'; -import { Component, ElementRef, OnInit, SecurityContext, Type, ViewChild } from '@angular/core'; -import { WindowComponent, WindowDelegate } from '../../window/window-delegate'; -import { TerminalAPI, TerminalState } from './terminal-api'; -import { DefaultTerminalState } from './terminal-states'; -import { WebsocketService } from '../../../websocket.service'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; -import { FileService } from '../../../api/files/file.service'; -import { WindowManager } from '../../window-manager/window-manager'; +import {SettingsService} from '../settings/settings.service'; +import {Component, ElementRef, OnInit, SecurityContext, Type, ViewChild} from '@angular/core'; +import {WindowComponent, WindowDelegate} from '../../window/window-delegate'; +import {TerminalAPI, TerminalState} from './terminal-api'; +import {DefaultTerminalState} from './terminal-states'; +import {WebsocketService} from '../../../websocket.service'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; +import {FileService} from '../../../api/files/file.service'; +import {WindowManager} from '../../window-manager/window-manager'; // noinspection AngularMissingOrInvalidDeclarationInModule @Component({ @@ -15,9 +15,9 @@ import { WindowManager } from '../../window-manager/window-manager'; styleUrls: ['./terminal.component.scss'] }) export class TerminalComponent extends WindowComponent implements OnInit, TerminalAPI { - @ViewChild('history', { static: true }) history: ElementRef; - @ViewChild('prompt', { static: true }) prompt: ElementRef; - @ViewChild('cmdLine', { static: true }) cmdLine: ElementRef; + @ViewChild('history', {static: true}) history: ElementRef; + @ViewChild('prompt', {static: true}) prompt: ElementRef; + @ViewChild('cmdLine', {static: true}) cmdLine: ElementRef; currentState: TerminalState[] = []; promptHtml: SafeHtml; @@ -107,10 +107,11 @@ export class TerminalComponent extends WindowComponent implements OnInit, Termin } autocomplete(content: string) { - const completed = this.getState().autocomplete(content); - if (completed) { - this.cmdLine.nativeElement.value = completed; - } + this.getState().autocomplete(content).then((completed) => { + if (completed) { + this.cmdLine.nativeElement.value = completed; + } + }); } previousFromHistory() { diff --git a/src/app/shell/builtins/builtins.ts b/src/app/shell/builtins/builtins.ts new file mode 100644 index 00000000..28056e4f --- /dev/null +++ b/src/app/shell/builtins/builtins.ts @@ -0,0 +1,10 @@ +import {Status} from './status'; +import {Hostname} from './hostname'; +import {Miner} from './miner'; +import {Cd} from './cd'; +import {Ls} from './ls'; +import {Clear} from './clear'; +import {Exit} from './exit'; + +export const BUILTINS = [Status, Hostname, Miner, Cd, Ls, Clear, Exit]; + diff --git a/src/app/shell/builtins/cd.ts b/src/app/shell/builtins/cd.ts new file mode 100644 index 00000000..0abb93b0 --- /dev/null +++ b/src/app/shell/builtins/cd.ts @@ -0,0 +1,43 @@ +import {Command, IOHandler, ArgType} from '../command'; +import {ShellApi} from '../shellapi'; +import {Path} from 'src/app/api/files/path'; + +export class Cd extends Command { + constructor(shellApi: ShellApi) { + super('cd', shellApi); + this.addDescription('changes the working directory'); + this.addPositionalArgument({name: 'directory', optional: true, argType: ArgType.DIRECTORY}); + } + + async run(iohandler: IOHandler): Promise { + const args = iohandler.positionalArgs; + const input = args.length === 1 ? args[0] : '/'; + let path: Path; + try { + path = Path.fromString(input, this.shellApi.working_dir); + } catch { + iohandler.stderr('The specified path is not valid'); + return 1; + } + let file: any; + try { + file = await this.shellApi.fileService.getFromPath(this.shellApi.activeDevice['uuid'], path).toPromise(); + } catch (error) { + if (error.message === 'file_not_found') { + iohandler.stderr('That directory does not exist'); + return 2; + } else { + this.reportError(error); + return 1; + } + } + if (file.is_directory) { + this.shellApi.working_dir = file.uuid; + this.shellApi.refreshPrompt(); + return 0; + } else { + iohandler.stderr('That is not a directory'); + return 1; + } + } +} diff --git a/src/app/shell/builtins/clear.ts b/src/app/shell/builtins/clear.ts new file mode 100644 index 00000000..bb4cbe81 --- /dev/null +++ b/src/app/shell/builtins/clear.ts @@ -0,0 +1,14 @@ +import {Command, IOHandler} from '../command'; +import {ShellApi} from '../shellapi'; + +export class Clear extends Command { + constructor(shellApi: ShellApi) { + super('clear', shellApi); + this.addDescription('clears the terminal'); + } + + async run(_: IOHandler): Promise { + this.shellApi.terminal.clear(); + return 0; + } +} diff --git a/src/app/shell/builtins/exit.ts b/src/app/shell/builtins/exit.ts new file mode 100644 index 00000000..631928d6 --- /dev/null +++ b/src/app/shell/builtins/exit.ts @@ -0,0 +1,14 @@ +import {Command, IOHandler} from '../command'; +import {ShellApi} from '../shellapi'; + +export class Exit extends Command { + constructor(shellApi: ShellApi) { + super('exit', shellApi); + this.addDescription('closes the terminal or leaves another device'); + } + + async run(_: IOHandler): Promise { + this.shellApi.terminal.popState(); + return 0; + } +} diff --git a/src/app/shell/builtins/hostname.ts b/src/app/shell/builtins/hostname.ts new file mode 100644 index 00000000..5c8c7aa2 --- /dev/null +++ b/src/app/shell/builtins/hostname.ts @@ -0,0 +1,51 @@ +import {Command, IOHandler} from '../command'; +import {Device} from '../../api/devices/device'; +import {ShellApi} from '../shellapi'; + +export class Hostname extends Command { + constructor(shellApi: ShellApi) { + super('hostname', shellApi); + this.addDescription('changes the name of the device'); + this.addPositionalArgument({name: 'name', optional: true}); + } + + async run(iohandler: IOHandler): Promise { + const args = iohandler.positionalArgs; + if (args.length === 1) { + const hostname = args[0]; + let newDevice: Device; + try { + newDevice = await this.shellApi.websocket.ms('device', ['device', 'change_name'], { + device_uuid: this.shellApi.activeDevice['uuid'], + name: hostname + }).toPromise(); + } catch { + iohandler.stderr('The hostname couldn\'t be changed'); + return 1; + } + this.shellApi.activeDevice = newDevice; + this.shellApi.refreshPrompt(); + + if (this.shellApi.activeDevice.uuid === this.shellApi.windowDelegate.device.uuid) { + Object.assign(this.shellApi.windowDelegate.device, newDevice); + } + } else { + let device: Device; + try { + device = await this.shellApi.websocket.ms( + 'device', + ['device', 'info'], + {device_uuid: this.shellApi.activeDevice['uuid']} + ).toPromise(); + } catch { + iohandler.stdout(this.shellApi.activeDevice['name']); + } + if (device['name'] !== this.shellApi.activeDevice['name']) { + this.shellApi.activeDevice = device; + this.shellApi.refreshPrompt(); + } + iohandler.stdout(device['name']); + } + return 0; + } +} diff --git a/src/app/shell/builtins/ls.ts b/src/app/shell/builtins/ls.ts new file mode 100644 index 00000000..bc242fb8 --- /dev/null +++ b/src/app/shell/builtins/ls.ts @@ -0,0 +1,54 @@ +import {Command, IOHandler, ArgType} from '../command'; +import {ShellApi} from '../shellapi'; +import {Path} from 'src/app/api/files/path'; +import {File} from 'src/app/api/files/file'; + +export class Ls extends Command { + constructor(shellApi: ShellApi) { + super('ls', shellApi); + this.addDescription('shows files of the current working directory'); + this.addPositionalArgument({name: 'path', optional: true, argType: ArgType.PATH}); + } + + async run(iohandler: IOHandler): Promise { + const args = iohandler.positionalArgs; + let files: File[]; + if (args.length === 0) { + files = await this.shellApi.listFilesOfWorkingDir(); + } else { + let path: Path; + try { + path = Path.fromString(args[0], this.shellApi.working_dir); + } catch { + iohandler.stderr('The specified path is not valid'); + return 1; + } + try { + const target = await this.shellApi.fileService.getFromPath(this.shellApi.activeDevice['uuid'], path).toPromise(); + if (target.is_directory) { + files = await this.shellApi.fileService.getFiles(this.shellApi.activeDevice['uuid'], target.uuid).toPromise(); + } else { + files = [target]; + } + } catch (error) { + if (error.message === 'file_not_found') { + iohandler.stderr('That directory does not exist'); + return 2; + } else { + this.reportError(error); + return 1; + } + } + } + + files.filter((file) => file.is_directory).sort().forEach(folder => { + // TODO use escape codes + iohandler.stdout(`${(this.shellApi.settings.getLSPrefix()) ? '[Folder] ' : ''}${folder.filename}`); + }); + + files.filter((file) => !file.is_directory).sort().forEach(file => { + iohandler.stdout(`${(this.shellApi.settings.getLSPrefix() ? '[File] ' : '')}${file.filename}`); + }); + return 0; + } +} diff --git a/src/app/shell/builtins/miner.ts b/src/app/shell/builtins/miner.ts new file mode 100644 index 00000000..43426469 --- /dev/null +++ b/src/app/shell/builtins/miner.ts @@ -0,0 +1,142 @@ +import {Command, IOHandler} from '../command'; +import {ShellApi} from '../shellapi'; + +export class Miner extends Command { + constructor(shellApi: ShellApi) { + super('miner', shellApi); + this.addDescription('mangages morphcoin miners'); + this.addSubcommand(MinerLook); + this.addSubcommand(MinerWallet); + this.addSubcommand(MinerPower); + this.addSubcommand(MinerStart); + } + + async run(iohandler: IOHandler): Promise { + this.showHelp(iohandler.stderr); + return 1; + } +} + +async function getMinerService(shellApi: ShellApi): Promise { + const listData = await shellApi.websocket.msPromise('service', ['list'], { + 'device_uuid': shellApi.activeDevice['uuid'], + }); + for (const service of listData.services) { + if (service.name === 'miner') { + return service; + } + } + throw new Error('miner service not found'); +} + +class MinerLook extends Command { + constructor(shellApi: ShellApi) { + super('look', shellApi); + this.addDescription('shows your current miner settings'); + } + + async run(iohandler: IOHandler): Promise { + let miner: any; + try { + miner = await getMinerService(this.shellApi); + } catch { + iohandler.stderr('Miner service not reachable'); + return 1; + } + const data = await this.shellApi.websocket.msPromise('service', ['miner', 'get'], { + 'service_uuid': miner.uuid, + }); + const wallet = data['wallet']; + const power = Math.round(data['power'] * 100); + iohandler.stdout('Wallet: ' + wallet); + iohandler.stdout('Mining Speed: ' + String(Number(miner.speed) * 60 * 60) + ' MC/h'); + iohandler.stdout('Power: ' + power + '%'); + return 0; + } +} + +class MinerWallet extends Command { + constructor(shellApi: ShellApi) { + super('wallet', shellApi); + this.addDescription('set the miner to a wallet'); + this.addPositionalArgument({name: 'wallet-id'}); + } + + async run(iohandler: IOHandler): Promise { + let miner: any; + try { + miner = await getMinerService(this.shellApi); + } catch { + iohandler.stderr('Miner service not reachable'); + return 1; + } + try { + const newWallet = iohandler.positionalArgs[0]; + await this.shellApi.websocket.msPromise('service', ['miner', 'wallet'], { + 'service_uuid': miner.uuid, + 'wallet_uuid': newWallet, + }); + iohandler.stdout(`Set wallet to ${newWallet}`); + return 0; + } catch { + iohandler.stderr('Wallet is invalid.'); + return 1; + } + } +} + +class MinerPower extends Command { + constructor(shellApi: ShellApi) { + super('power', shellApi); + this.addDescription('set the power of your miner'); + // TODO add validators + this.addPositionalArgument({name: '<0-100>'}); + } + + async run(iohandler: IOHandler): Promise { + const args = iohandler.positionalArgs; + let miner: any; + try { + miner = await getMinerService(this.shellApi); + } catch { + iohandler.stderr('Miner service not reachable'); + return 1; + } + if (args.length !== 1 || isNaN(Number(args[0])) || 0 > Number(args[0]) || Number(args[0]) > 100) { + this.showHelp(iohandler.stderr); + return 1; + } + await this.shellApi.websocket.msPromise('service', ['miner', 'power'], { + 'service_uuid': miner.uuid, + 'power': Number(args[1]) / 100, + }); + iohandler.stdout(`Set Power to ${args[0]}`); + return 0; + } +} + + +class MinerStart extends Command { + constructor(shellApi: ShellApi) { + super('start', shellApi); + this.addDescription('start the miner'); + this.addPositionalArgument({name: 'wallet-id'}); + } + + async run(iohandler: IOHandler): Promise { + const args = iohandler.positionalArgs; + try { + await this.shellApi.websocket.msPromise('service', ['create'], { + 'device_uuid': this.shellApi.activeDevice['uuid'], + 'name': 'miner', + 'wallet_uuid': args[0], + }); + return 0; + } catch { + iohandler.stderr('Invalid wallet'); + return 1; + } + } +} + + diff --git a/src/app/shell/builtins/status.ts b/src/app/shell/builtins/status.ts new file mode 100644 index 00000000..41760f74 --- /dev/null +++ b/src/app/shell/builtins/status.ts @@ -0,0 +1,17 @@ +import {Command, IOHandler} from '../command'; +import {ShellApi} from '../shellapi'; + +export class Status extends Command { + constructor(shellApi: ShellApi) { + super('status', shellApi); + this.addDescription('displays the number of online players'); + } + + async run(iohandler: IOHandler): Promise { + const r = await this.shellApi.websocket.requestPromise({ + action: 'info' + }); + iohandler.stdout('Online players: ' + r.online); + return 0; + } +} diff --git a/src/app/shell/builtins/template.ts b/src/app/shell/builtins/template.ts new file mode 100644 index 00000000..ba50d28e --- /dev/null +++ b/src/app/shell/builtins/template.ts @@ -0,0 +1,13 @@ +import {Command, IOHandler} from '../command'; +import {ShellApi} from '../shellapi'; + +export class Template extends Command { + constructor(shellApi: ShellApi) { + super('COMMANDNAME', shellApi); + this.addDescription(''); + } + + async run(iohandler: IOHandler): Promise { + return -1; + } +} diff --git a/src/app/shell/command.ts b/src/app/shell/command.ts new file mode 100644 index 00000000..053830d8 --- /dev/null +++ b/src/app/shell/command.ts @@ -0,0 +1,151 @@ +import {ShellApi} from './shellapi'; + +export enum ArgType { + RAW, // just a String + PATH, // FILE or DIRECTORY + FILE, // only FILE + DIRECTORY // only DIRECTORY +} + +export interface PositionalArgument { + name: string; + optional?: boolean; + captures?: boolean; + argType?: ArgType; +} + +export abstract class Command { + public description = ''; + private positionalArgs: PositionalArgument[] = []; + public optionalArgs = 0; + public capturesAllArgs = false; + private subcommands: Map = new Map(); + + // TODO add named arguments + constructor(public name: string, protected shellApi: ShellApi, public hidden: boolean = false) { + } + + // this weird parameter is necessary so that the following works if SubCommand is a Command: + // someCommand.addSubcommand(SubCommand); + // + // This removes avoidable code duplication of calling the constructor + // for every subcommand in the supercommand class + addSubcommand(command: new (shellApi: ShellApi) => T) { + const cmd = new command(this.shellApi); + const name = cmd.name; + this.subcommands.set(name, cmd); + } + + addDescription(description: string) { + this.description = description; + } + + addPositionalArgument({name, optional = false, captures = false, argType = ArgType.RAW}: PositionalArgument) { + if (this.capturesAllArgs) { + throw Error('only the last argument can capture multiple words'); + } else if (this.optionalArgs > 0 && !optional) { + throw Error('optional args must be last'); + } + if (optional) { + this.optionalArgs++; + } + this.capturesAllArgs = captures; + this.positionalArgs.push({name, optional, captures, argType}); + } + + showHelp(stdout: (text: string) => void) { + const positionalArgText = this.positionalArgs.map((arg) => arg.optional ? `[${arg.name}]` : arg.name).join(' '); + stdout(`usage: ${this.name} ${positionalArgText}`); + stdout(this.description); + if (this.subcommands.size > 0) { + stdout('subcommands:'); + this.subcommands.forEach((subcommand: Command, name: string) => { + // TODO use \t + // TODO align the descriptions + stdout(` ${name} - ${subcommand.description}`); + }); + } + } + + /** will be executed by the shell + * @returns the exit code once the command is completed + */ + public async execute(iohandler: IOHandler): Promise { + const args = iohandler.positionalArgs; + const subcommand = this.subcommands.get(args[0]); + if (subcommand !== undefined) { + // remove the subcommand name + iohandler.positionalArgs = args.slice(1); + return await subcommand.execute(iohandler); + } + if (args.length > 0 && args.find((arg) => arg === '--help')) { + this.showHelp(iohandler.stdout); + return 0; + } + const posArgsLen = this.positionalArgs.length; + if (posArgsLen < args.length && this.capturesAllArgs + || args.length <= posArgsLen && args.length >= posArgsLen - this.optionalArgs + ) { + return await this.run(iohandler); + } + this.showHelp(iohandler.stdout); + return 1; + } + + + /** will be called with the arguments only if there is no subcommand matching + * @returns the exit code once the command is completed + */ + abstract async run(iohandler: IOHandler): Promise; + + + reportError(error: any) { + console.warn(new Error(error.message)); + } + + async autocomplete(words: string[]): Promise { + if (words.length === 0) { + return ''; + } + if (this.subcommands.has(words[0])) { + const sub = this.subcommands.get(words[0]).autocomplete(words.slice(1)); + return `${words[0]} ${sub}`; + } + let found: any = [...this.subcommands] + .filter(command => !command[1].hidden) + .map(([name]) => name) + .sort() + .find(n => n.startsWith(words[0])); + if (found) { + return found; + } + // autocomplete the last word if its type is a file or directory + const arg = this.positionalArgs[words.length - 1]; + let files: any; + if (arg.argType === ArgType.DIRECTORY || arg.argType === ArgType.PATH) { + files = await this.shellApi.listFilesOfWorkingDir(); + found = files.filter(n => n.is_directory).find(n => n.filename.startsWith(words[0])); + if (found) { + return found.filename; + } + } + if (arg.argType === ArgType.FILE || arg.argType === ArgType.PATH) { + files = await this.shellApi.listFilesOfWorkingDir(); + found = files.filter(n => !n.is_directory).find(n => n.filename.startsWith(words[0])); + if (found) { + return found.filename; + } + } + // if there is nothing to complete, just give the input unchanged back + return words.join(' '); + } +} + + +export class IOHandler { + stdout: (stdout: string) => void; + stdin: (callback: (stdin: string) => void) => void; + stderr: (stderr: string) => void; + positionalArgs: string[]; +} + diff --git a/src/app/shell/shell.ts b/src/app/shell/shell.ts new file mode 100644 index 00000000..54a2e79d --- /dev/null +++ b/src/app/shell/shell.ts @@ -0,0 +1,143 @@ +import {BUILTINS} from './builtins/builtins'; +import {IOHandler, Command} from './command'; +import {ShellApi} from './shellapi'; + +export class Shell { + private history: string[] = []; + public commands: Map = new Map(); + private variables: Map = new Map(); + private aliases: Map = new Map(); + + constructor( + private stdoutHandler: (stdout: string) => void, + private stdinHandler: (callback: (stdin: string) => void) => void, + private stderrHandler: (stderr: string) => void, + public api: ShellApi, + ) { + BUILTINS.forEach((command) => { + const cmd = new command(this.api); + this.commands.set(cmd.name, cmd); + }); + this.aliases.set('l', 'ls'); + this.aliases.set('dir', 'ls'); + this.aliases.set('quit', 'exit'); + } + + async executeCommand(command: string, io: IOHandler = null): Promise { + const iohandler = io ? io : { + stdout: this.stdoutHandler.bind(this), + stdin: this.stdinHandler.bind(this), + stderr: this.stderrHandler.bind(this), + positionalArgs: [], + }; + command = command.toLowerCase(); + if (this.commands.has(command)) { + return await this.commands.get(command).execute(iohandler); + } else if (this.aliases.has(command)) { + const cmd = this.aliases.get(command); + return await this.commands.get(cmd).execute(iohandler); + } else if (command === 'help') { + return await this.help(iohandler); + } else if (command !== '') { + iohandler.stderr('Command could not be found.\nType `help` for a list of commands.'); + return 127; + } + } + + async executeCommandChain( + commands: string[], + previousStdout: string = null + ): Promise { + let stdoutText = ''; + + const pipedStdout = (output: string) => { + stdoutText = stdoutText + output + '\n'; + }; + + const pipedStdin = (callback: (input: string) => void) => { + callback(previousStdout); + }; + + let command = commands[0].trim().split(' '); + if (command.length === 0) { + return await this.executeCommandChain(commands.slice(1)); + } + // replace variables with their values + command = command.map((arg) => { + if (arg.startsWith('$')) { + const name = arg.slice(1); + if (this.variables.has(name)) { + return this.variables.get(name); + } + return ''; + } + return arg; + }); + + const stdout = commands.length > 1 ? pipedStdout : this.stdoutHandler.bind(this); + const stdin = previousStdout ? pipedStdin : this.stdinHandler.bind(this); + const iohandler: IOHandler = {stdout: stdout, stdin: stdin, stderr: this.stderrHandler.bind(this), positionalArgs: command.slice(1)}; + await this.executeCommand(command[0], iohandler); + if (commands.length > 1) { + this.executeCommandChain(commands.slice(1), stdoutText); + } + } + + execute(cmd: string) { + let commands = cmd.trim().split(';'); + commands = [].concat(...commands.map((command) => command.split('\n'))); + commands.forEach((command) => { + const pipedCommands = command.trim().split('|'); + this.executeCommandChain(pipedCommands).then((exitCode) => { + this.variables.set('?', String(exitCode)); + }); + }); + if (cmd) { + this.history.unshift(cmd); + } + } + + getExitCode(): number { + return Number(this.variables.get('?')); + } + + getHistory(): string[] { + return this.history; + } + + async autocomplete(content: string): Promise { + const words = content.split(' '); + if (this.commands.has(words[0])) { + const sub = await this.commands.get(words[0]).autocomplete(words.slice(1)); + return `${words[0]} ${sub}`; + } + return content + ? [...this.commands] + .filter(command => !command[1].hidden) + .map(([name]) => name) + .sort() + .find(n => n.startsWith(content)) + : ''; + } + + + // help command has to be here because + // help has to be able to access all commands + async help(iohandler: IOHandler): Promise { + const args = iohandler.positionalArgs; + if (args.length === 0) { + this.commands.forEach((cmd, name) => { + iohandler.stdout(`${name} - ${cmd.description}`); + }); + return 0; + } else { + if (this.commands.has(args[0])) { + this.commands.get(args[0]).showHelp(iohandler.stdout); + return 0; + } else { + iohandler.stderr(`help: no help topics match '${args[0]}'.`); + return 1; + } + } + } +} diff --git a/src/app/shell/shellapi.ts b/src/app/shell/shellapi.ts new file mode 100644 index 00000000..b954ee68 --- /dev/null +++ b/src/app/shell/shellapi.ts @@ -0,0 +1,28 @@ +import {TerminalAPI} from '../desktop/windows/terminal/terminal-api'; +import {WebsocketService} from '../websocket.service'; +import {DomSanitizer} from '@angular/platform-browser'; +import {SettingsService} from '../desktop/windows/settings/settings.service'; +import {FileService} from '../api/files/file.service'; +import {Device} from '../api/devices/device'; +import {WindowDelegate} from '../desktop/window/window-delegate'; +import {File} from 'src/app/api/files/file'; + + +export class ShellApi { + constructor( + public websocket: WebsocketService, + public settings: SettingsService, + public fileService: FileService, + public domSanitizer: DomSanitizer, + public windowDelegate: WindowDelegate, + public activeDevice: Device, + public terminal: TerminalAPI, + public promptColor: string, + public refreshPrompt: () => void, + public working_dir: string + ) {} + + public async listFilesOfWorkingDir(): Promise { + return await this.fileService.getFiles(this.activeDevice['uuid'], this.working_dir).toPromise(); + } +} diff --git a/src/app/websocket.service.ts b/src/app/websocket.service.ts index 868ccbc4..174693e2 100644 --- a/src/app/websocket.service.ts +++ b/src/app/websocket.service.ts @@ -91,6 +91,10 @@ export class WebsocketService { return this.socketSubject.pipe(first(), map(checkResponseError)); // this will soon get tags too } + requestPromise(data: object): Promise { + return this.request(data).toPromise(); + } + ms(name: string, endpoint: string[], data: object): Observable { const tag = randomUUID(); if (this.socketSubject.closed || this.socketSubject.hasError) {