// Web-Uno Shell // the best name I could come up with lol import { Clear } from '../../program/Clear' import { Eval } from '../../program/Eval' import { Loadprg } from '../../program/Loadprg' import { Lsprg } from '../../program/Lsprg' import { Info } from '../../program/Info' import { Program } from '../../program/Program' import { Terminal } from '../../terminal/Terminal' import type { CursorPosition } from '../../terminal/CursorProperties' import { EventBroadcaster } from '../../utils/EventBroadcaster' import { SimpleStream } from '../../utils/SimpleStream' import { Shell } from '../Shell' import { Ls } from '../../program/Ls' import { Item } from '../../fs/Item' import { Touch } from '../../program/Touch' import { Sl } from '../../program/Sl' import { Rm } from '../../program/Rm' import { Rl } from '../../program/Rl' import { ResetIndexedDb } from '../../program/ResetIndexedDb' import { Cat } from '../../program/Cat' import { Echo } from '../../program/Echo' import { Mkdir } from '../../program/Mkdir' import { Cd } from '../../program/Cd' import { Printf } from '../../program/Printf' import { Pwd } from '../../program/Pwd' import { Environment } from './Environment' import { InputManager } from './InputManager' export class Wush extends Shell { public readonly Version = "0.3.2" public readonly Name = "wush" // buffer _buffer: string[] = [] _bufferPos: number = 0 _promptStart: CursorPosition = { col: 0, row: 0 } private inputManager: InputManager // @ts-ignore unused for now private environment: Environment // exec stuff /** * -1 if the exec is currently running and anything else when it's not */ private execExitCode: number = 0 // streams readonly stdin: SimpleStream readonly stdout: SimpleStream private programs: { [name: string]: Program } = {} private workingDirectory: Item = null as unknown as Item // workdir is initialized in Init so this should be safe constructor(broadcaster: EventBroadcaster, terminal: Terminal) { super(broadcaster, terminal) // create streams this.stdin = new SimpleStream() this.stdout = new SimpleStream() this.inputManager = new InputManager(this, broadcaster) this.environment = new Environment() } async Init() { // initialize keyboard listener this.inputManager.RegisterEvents() // load workdir this.workingDirectory = await Item.Root() // register streams this.stdout.on(data => this.WriteEscapedString(data)) // load core programs this.programs['clear'] = new Clear() this.programs['eval'] = new Eval() this.programs['loadprg'] = new Loadprg(this) this.programs['lsprg'] = new Lsprg(this) this.programs['info'] = new Info() this.programs['ls'] = new Ls() this.programs['touch'] = new Touch() this.programs['sl'] = new Sl() this.programs['rm'] = new Rm() this.programs['rl'] = new Rl() this.programs['rsindb'] = new ResetIndexedDb() this.programs['cat'] = new Cat() this.programs['echo'] = new Echo() this.programs['mkdir'] = new Mkdir() this.programs['pwd'] = new Pwd() this.programs['cd'] = new Cd(this) this.programs['printf'] = new Printf() // initial prompt this.Prompt() } HasRunningProgram(): boolean { return this.execExitCode === -1 } GetPrograms(): { [name: string]: Program } { return this.programs } LoadProgram(program: Program, name: string) { this.programs[name] = program } UnloadProgram(name: string) { delete this.programs[name] } GetExecExitCode(): number { return this.execExitCode } async ExecuteProgram(name: string, args: string[]): Promise { return new Promise(resolve => { this.programs[name].Exec(this.stdin, this.stdout, this.workingDirectory, args) .then(code => { this.execExitCode = code != -1 ? code : -2 resolve() }) .catch((e) => { this.WriteEscapedString(`wush: command ${name} exited with the following exception\n`) this.WriteEscapedString(` | ${String(e)}\n`) resolve() }) .finally(() => { // check if the exec actually exited with an exit code // and if not, set it to -2 to indicate that it didn't exit with a valid code if (this.execExitCode === -1) this.execExitCode = -2 // this.terminal.Write(`The program exited with exit code ${this.execExitCode}.`) resolve() }) }) } Prompt() { this.terminal.Write(`user in ${this.workingDirectory.GetPath()} -> `) this._promptStart = this.terminal.GetCursorPosition() this._buffer = [] this._bufferPos = 0 this.inputManager.historyIndex = null this.inputManager.historyDraft = '' } /** * Changes the working directory * @param directory the directory to enter into * @throws an error if the directory cannot be opened */ async SetWorkingDirectory(directory: Item) { if (!(await directory.Exists()) || !directory.IsDirectory()) throw new Error(`Directory ${directory.GetPath()} doesn't exist`) this.workingDirectory = directory } WriteStdin(data: string) { this.stdin.emit(data) } WriteEscapedString(data: string) { let buffer = '' // write the buffer to screen const flush = () => { if (buffer.length === 0) return this.terminal.Write(buffer) buffer = '' } // process the string let i = 0 while (i < data.length) { const char = data[i] // check for escape sequences if (char === '\x1b') { flush() const nextIndex = this.ProcessEscapeSequence(data, i) if (nextIndex !== null) { i = nextIndex continue } } // check for control codes if (char === '\f' || char === '\n') { flush() this.ProcessControlCode(char) i++ continue } buffer += char i++ } flush() } PushToBuffer(text: string) { text.split('').forEach(char => { this._buffer.splice(this._bufferPos, 0, char) this._bufferPos++ }) } RemoveCharFromBuffer(amount: number, index: number) { this._buffer.splice(index - amount, amount) this._bufferPos -= amount } MoveBufferPos(index: number, absolute: boolean = false) { this._bufferPos = Math.max(absolute ? index : this._bufferPos + index, 0) } FlushBuffer() { this._buffer = [] this._bufferPos = 0 } // takes the prompt buffer, parses it and executes the contents async ExecuteLineBuffer(data?: string) { type OperatorToken = '&&' | '||' | '|' | ';' type Token = { type: 'word'; value: string } | { type: 'operator'; value: OperatorToken } type ParsedCommand = { args: string[] stdin: null | SimpleStream stdout: null | SimpleStream } type ParsedPipeline = { commands: ParsedCommand[] } type ParsedListItem = { pipeline: ParsedPipeline; operator: '&&' | '||' | ';' | null } // splits the buffer into processable pieces (tokens) const tokenize = (text: string): { tokens: Token[]; error: string | null } => { const tokens: Token[] = [] // state machine stuff let current = '' let wordStarted = false let inSingleQuote = false let inDoubleQuote = false let escapeNext = false /** * adds a new word to the token buffer */ const pushWord = () => { if (!wordStarted) return tokens.push({ type: 'word', value: current }) // reset the state current = '' wordStarted = false } // iterates over the provided text buffer for (let i = 0; i < text.length; i++) { const char = text[i] if (escapeNext) { current += char escapeNext = false wordStarted = true continue } // process strict (single quote) strings if (inSingleQuote) { if (char === "'") { inSingleQuote = false wordStarted = true } else { current += char wordStarted = true } continue } // process classic (double quote) strings if (inDoubleQuote) { if (char === '"') { inDoubleQuote = false wordStarted = true } else if (char === '\\') { if (i + 1 >= text.length) return { tokens: [], error: 'unterminated escape sequence' } current += text[i + 1] i++ wordStarted = true } else { current += char wordStarted = true } continue } // state checks // escapes if (char === '\\') { escapeNext = true wordStarted = true continue } // single quote strings if (char === "'") { inSingleQuote = true wordStarted = true continue } // double quote strings if (char === '"') { inDoubleQuote = true wordStarted = true continue } if (char === ' ' || char === '\t') { pushWord() continue } if (char === '#') { if (!wordStarted) break current += char wordStarted = true continue } if (char === ';') { pushWord() tokens.push({ type: 'operator', value: ';' }) continue } if (char === '|') { pushWord() if (text[i + 1] === '|') { tokens.push({ type: 'operator', value: '||' }) i++ } else { tokens.push({ type: 'operator', value: '|' }) } continue } if (char === '&') { if (text[i + 1] === '&') { pushWord() tokens.push({ type: 'operator', value: '&&' }) i++ } else { current += char wordStarted = true } continue } current += char wordStarted = true } if (escapeNext) return { tokens: [], error: 'unterminated escape sequence' } if (inSingleQuote) return { tokens: [], error: 'unterminated single quote' } if (inDoubleQuote) return { tokens: [], error: 'unterminated double quote' } pushWord() return { tokens, error: null } } // parse the tokens into a list of parsed commands and pipelines const parseTokens = (tokens: Token[]): { list: ParsedListItem[]; error: string | null } => { const list: ParsedListItem[] = [] let currentArgs: string[] = [] let currentPipeline: ParsedCommand[] = [] let expectCommand = false const pushCommand = () => { if (currentArgs.length === 0) return false currentPipeline.push({ args: currentArgs, stdin: null, stdout: null, }) currentArgs = [] return true } const pushPipeline = (operator: '&&' | '||' | ';' | null) => { if (currentPipeline.length === 0) return false list.push({ pipeline: { commands: currentPipeline }, operator }) currentPipeline = [] return true } for (const token of tokens) { if (token.type === 'word') { currentArgs.push(token.value) expectCommand = false continue } if (token.value === '|') { if (!pushCommand()) return { list: [], error: 'unexpected "|" operator' } expectCommand = true continue } if (!pushCommand() || !pushPipeline(token.value)) return { list: [], error: `unexpected "${token.value}" operator` } expectCommand = true } if (expectCommand) return { list: [], error: 'unexpected end of input' } if (currentArgs.length > 0) pushCommand() if (currentPipeline.length > 0) pushPipeline(null) return { list, error: null } } // process the buffer let line: string if (data) { // data overrides the buffer this._buffer = data.split('') this._bufferPos = 0 line = data } else { // use shell buffer if no arbitrary string was provided line = this._buffer.join('') this.FlushBuffer() } const { tokens, error } = tokenize(line) if (error) { this.terminal.Write(`wush: error: ${error}`) this.terminal.MoveCursor(0, 1, { x: true, y: false }) this.Prompt() return } const { list, error: parseError } = parseTokens(tokens) if (parseError) { this.terminal.Write(`wush: error: ${parseError}`) this.terminal.MoveCursor(0, 1, { x: true, y: false }) this.Prompt() return } if (list.length === 0) { this.Prompt() return } if (list.some(item => item.pipeline.commands.length > 1)) { this.terminal.Write(`wush: error: pipes are not supported yet`) this.terminal.MoveCursor(0, 1, { x: true, y: false }) this.Prompt() return } for (const item of list) { const command = item.pipeline.commands[0] this.execExitCode = -1 if (this.programs[command.args[0]] instanceof Program) { await this.ExecuteProgram(command.args[0], command.args) } else { this.execExitCode = 0 // don't print anything if there are no arguments if (command.args[0].length !== 0) { this.terminal.Write(`wush: error: unknown command: ${command.args[0]}.`) this.terminal.MoveCursor(0, 1, { x: true, y: false }) this.execExitCode = 127 } } } this.Prompt() } ProcessControlCode(code: string): boolean { switch (code) { case '\f': this.terminal.NewPage() return true case '\n': this.terminal.MoveCursor(0, 1, { x: true, y: false }) return true default: return false } } ProcessEscapeSequence(data: string, index: number): number | null { if (data[index] !== '\x1b' || data[index + 1] !== '[') return null let i = index + 2 let params = '' while (i < data.length) { const char = data[i] if ((char >= '0' && char <= '9') || char === ';') { params += char i++ continue } this.ProcessCsiControlCode(char, params) return i + 1 } return data.length } ProcessCsiControlCode(command: string, params: string): boolean { const values = params.length === 0 ? [] : params.split(';').map(value => Number.parseInt(value, 10)) switch (command) { case 'H': case 'f': { const row = Number.isFinite(values[0]) ? values[0] : 1 const col = Number.isFinite(values[1]) ? values[1] : 1 this.terminal.SetCursorPosition(Math.max(col - 1, 0), Math.max(row - 1, 0)) return true } case 'J': { this.terminal.NewPage() return true } default: return false } } private GetWrapWidth(): number { const width = Math.floor(this.terminal.GetWidthCells()) if (!Number.isFinite(width) || width <= 0) return 1 return Math.max(1, width) } private GetCursorPositionForBufferPos(pos: number): CursorPosition { const width = this.GetWrapWidth() const absoluteIndex = this._promptStart.col + Math.max(pos, 0) const rowOffset = Math.floor(absoluteIndex / width) const col = absoluteIndex % width return { row: this._promptStart.row + rowOffset, col } } _SyncCursorToBuffer() { const position = this.GetCursorPositionForBufferPos(this._bufferPos) this.terminal.SetCursorPosition(position.col, position.row) } private ClearBuffer() { if (this._buffer.length === 0) return if (this._bufferPos < this._buffer.length) { this._bufferPos = this._buffer.length this._SyncCursorToBuffer() } while (this._bufferPos > 0) { this.terminal.RemoveCell() this.RemoveCharFromBuffer(1, this._bufferPos) this._SyncCursorToBuffer() } } _InsertText(text: string) { if (text.length === 0) return for (const char of text) { this.terminal.InsertCell(char) this.PushToBuffer(char) this._SyncCursorToBuffer() } } _SetBuffer(text: string) { this.ClearBuffer() this._InsertText(text) } }