// 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' import { Edit } from '../../program/Edit' import { Mv } from '../../program/Mv' import { Tree } from '../../program/Tree' import { Cp } from '../../program/Cp' 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 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() this.programs['edit'] = new Edit() this.programs['mv'] = new Mv() this.programs['cp'] = new Cp() this.programs['tree'] = new Tree() // reset exit code this.SetExitCode(0) // initial prompt this.Prompt() } SetExitCode(code: number): void { this.execExitCode = code this.environment.SetVariable("status", code) } 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.SetExitCode(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.SetExitCode(-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 stdinPath: string | null stdoutPath: string | null stdoutAppend: boolean } type ParsedPipeline = { commands: ParsedCommand[] } type ParsedListItem = { pipeline: ParsedPipeline; operator: '&&' | '||' | ';' | null } // environment variable helpers /** * @returns value of the variable, empty string otherwise */ const resolveVariable = (name: string): string => this.environment.GetVariable(name) ?? '' const isValidVarNameChar = (char: string) => /[A-Za-z0-9_]/.test(char) /** * splits the buffer into smaller 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 plain string to the token buffer */ const pushWord = () => { if (!wordStarted) return tokens.push({ type: 'word', value: current }) // reset the state current = '' wordStarted = false } // reads environment variable tokens const readVariable = (index: number): { value: string nextIndex: number error: string | null expanded: boolean } => { if (index + 1 >= text.length) return { value: '$', nextIndex: index, error: null, expanded: false } const next = text[index + 1] if (next === '{') { let end = index + 2 while (end < text.length && text[end] !== '}') end++ if (end >= text.length) return { value: '', nextIndex: text.length - 1, error: 'unterminated variable expansion', expanded: true } const name = text.slice(index + 2, end) if (name.length === 0) return { value: '', nextIndex: end, error: 'empty variable name', expanded: true } return { value: resolveVariable(name), nextIndex: end, error: null, expanded: true } } if (isValidVarNameChar(next)) { let end = index + 1 while (end + 1 < text.length && isValidVarNameChar(text[end + 1])) end++ const name = text.slice(index + 1, end + 1) return { value: resolveVariable(name), nextIndex: end, error: null, expanded: true } } return { value: '$', nextIndex: index, error: null, expanded: false } } const appendExpandedValue = (value: string, split: boolean, preserveEmpty: boolean) => { if (value.length === 0) { if (preserveEmpty) wordStarted = true return } if (!split) { current += value wordStarted = true return } for (const char of value) { if (char === ' ' || char === '\t') { pushWord() continue } current += char wordStarted = true } } // 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 if (char === '$') { const { value, nextIndex, error, expanded } = readVariable(i) if (error) return { tokens: [], error } if (expanded) { appendExpandedValue(value, false, true) i = nextIndex } else { current += char 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 === '$') { const { value, nextIndex, error, expanded } = readVariable(i) if (error) return { tokens: [], error } if (expanded) { appendExpandedValue(value, true, false) i = nextIndex } else { current += char 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 === '<') { 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 let expectRedirect: 'stdin' | 'stdout' | 'append' | null = null let currentStdinPath: string | null = null let currentStdoutPath: string | null = null let currentStdoutAppend = false const pushCommand = () => { if (currentArgs.length === 0) return false currentPipeline.push({ args: currentArgs, stdin: null, stdout: null, stdinPath: currentStdinPath, stdoutPath: currentStdoutPath, stdoutAppend: currentStdoutAppend, }) currentArgs = [] currentStdinPath = null currentStdoutPath = null currentStdoutAppend = false 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') { if (expectRedirect) { if (expectRedirect === 'stdin') { currentStdinPath = token.value } else { currentStdoutPath = token.value currentStdoutAppend = expectRedirect === 'append' } expectRedirect = null expectCommand = false continue } currentArgs.push(token.value) expectCommand = false continue } if (expectRedirect) return { list: [], error: 'expected redirection target' } if (token.value === '>' || token.value === '>>' || token.value === '<') { if (currentArgs.length === 0) return { list: [], error: `unexpected "${token.value}" operator` } expectRedirect = token.value === '<' ? 'stdin' : token.value === '>>' ? 'append' : 'stdout' continue } if (token.value === '|') { if (currentStdoutPath) return { list: [], error: 'unexpected "|" operator after redirection' } 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 (expectRedirect) return { list: [], error: 'expected redirection target' } 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 } const resolvePath = (path: string) => Item.NormalizePath( path.startsWith('/') ? path : `${this.workingDirectory.GetPath()}/${path}` ) const openInputRedirect = async (path: string) => { const resolved = resolvePath(path) const item = await Item.open(resolved) if (!(await item.Exists())) throw new Error(`input file ${resolved} doesn't exist`) if (item.IsDirectory()) throw new Error(`input file ${resolved} is a directory`) return { stream: new SimpleStream(), data: item.GetData() ?? '' } } const openOutputRedirect = async (path: string, append: boolean) => { const resolved = resolvePath(path) const item = await Item.open(resolved) if (!(await item.Exists())) await item.Create() if (item.IsDirectory()) throw new Error(`output path ${resolved} is a directory`) if (!append) await item.Write('') const stream = new SimpleStream() let writeQueue = Promise.resolve() const listener = (data: string) => { writeQueue = writeQueue.then(() => item.Append(data)) } stream.on(listener) return { stream, finish: () => writeQueue, teardown: () => stream.off(listener), } } const setupPipelineStreams = async (pipeline: ParsedPipeline) => { const teardown: Array<() => void> = [] const stdins: SimpleStream[] = [] const stdouts: SimpleStream[] = [] const finishers: Array<() => Promise> = [] const inputFeeds: Array<{ stream: SimpleStream; data: string }> = [] try { for (const [index, command] of pipeline.commands.entries()) { let stdin: SimpleStream let stdout: SimpleStream if (command.stdinPath) { if (index !== 0) throw new Error('input redirection is only supported on the first command in a pipeline') const { stream, data } = await openInputRedirect(command.stdinPath) stdin = stream inputFeeds.push({ stream, data }) } else { stdin = index === 0 ? this.stdin : new SimpleStream() } if (command.stdoutPath) { if (index !== pipeline.commands.length - 1) throw new Error('output redirection is only supported on the last command in a pipeline') const { stream, finish, teardown: remove } = await openOutputRedirect( command.stdoutPath, command.stdoutAppend ) stdout = stream finishers.push(finish) teardown.push(remove) } else { stdout = index === pipeline.commands.length - 1 ? this.stdout : new SimpleStream() } command.stdin = stdin command.stdout = stdout stdins.push(stdin) stdouts.push(stdout) } for (let i = 0; i < pipeline.commands.length - 1; i++) { if (pipeline.commands[i].stdoutPath) throw new Error('cannot pipe a command with output redirection') if (pipeline.commands[i + 1].stdinPath) throw new Error('cannot pipe into a command with input redirection') const listener = (data: string) => stdins[i + 1].emit(data) stdouts[i].on(listener) teardown.push(() => stdouts[i].off(listener)) } return { stdins, stdouts, teardown, finishers, inputFeeds } } catch (e) { teardown.forEach(remove => remove()) throw e } } const executePipeline = async (pipeline: ParsedPipeline): Promise => { const missing = pipeline.commands.find(command => !(this.programs[command.args[0]] instanceof Program)) if (missing) { const name = missing.args[0] if (name.length !== 0) { this.terminal.Write(`wush: error: unknown command: ${name}.`) this.terminal.MoveCursor(0, 1, { x: true, y: false }) return 127 } return 0 } let setup: Awaited> try { setup = await setupPipelineStreams(pipeline) } catch (e) { const message = e instanceof Error ? e.message : String(e) this.terminal.Write(`wush: error: ${message}`) this.terminal.MoveCursor(0, 1, { x: true, y: false }) return 1 } try { const exitCodes = await Promise.all(pipeline.commands.map(async (command, index) => { try { return await this.programs[command.args[0]].Exec( setup.stdins[index], setup.stdouts[index], this.workingDirectory, command.args ) } catch (e) { this.WriteEscapedString(`wush: command ${command.args[0]} exited with the following exception\n`) this.WriteEscapedString(` | ${String(e)}\n`) return -2 } })) setup.inputFeeds.forEach(feed => { queueMicrotask(() => feed.stream.emit(feed.data)) }) await Promise.all(setup.finishers.map(finish => finish())) const lastExitCode = exitCodes[exitCodes.length - 1] return lastExitCode !== -1 ? lastExitCode : -2 } finally { setup.teardown.forEach(remove => remove()) } } for (const item of list) { this.SetExitCode(-1) this.SetExitCode(await executePipeline(item.pipeline)) } 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 } case 'm': { this.terminal.ApplyCellStyleCodes(values) 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) } }