mirror of
				https://github.com/kepler155c/opus
				synced 2025-10-31 07:33:00 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			643 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Plaintext
		
	
	
	
	
	
			
		
		
	
	
			643 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Plaintext
		
	
	
	
	
	
| local parentShell = shell
 | |
| 
 | |
| shell = { }
 | |
| multishell = multishell or { }
 | |
| 
 | |
| local sandboxEnv = setmetatable({ }, { __index = _G })
 | |
| for k,v in pairs(getfenv(1)) do
 | |
|   sandboxEnv[k] = v 
 | |
| end
 | |
| sandboxEnv.shell = shell
 | |
| sandboxEnv.multishell = multishell
 | |
| 
 | |
| requireInjector(getfenv(1))
 | |
| 
 | |
| local Util = require('util')
 | |
| 
 | |
| local DIR = (parentShell and parentShell.dir()) or ""
 | |
| local PATH = (parentShell and parentShell.path()) or ".:/rom/programs"
 | |
| local ALIASES = (parentShell and parentShell.aliases()) or {}
 | |
| local tCompletionInfo = (parentShell and parentShell.getCompletionInfo()) or {}
 | |
| 
 | |
| local bExit = false
 | |
| local tProgramStack = {}
 | |
| 
 | |
| local function parseCommandLine( ... )
 | |
|   local sLine = table.concat( { ... }, " " )
 | |
|   local tWords = {}
 | |
|   local bQuoted = false
 | |
|   for match in string.gmatch( sLine .. "\"", "(.-)\"" ) do
 | |
|     if bQuoted then
 | |
|       table.insert( tWords, match )
 | |
|     else
 | |
|       for m in string.gmatch( match, "[^ \t]+" ) do
 | |
|         table.insert( tWords, m )
 | |
|       end
 | |
|     end
 | |
|     bQuoted = not bQuoted
 | |
|   end
 | |
| 
 | |
|   return table.remove(tWords, 1), tWords
 | |
| end
 | |
| 
 | |
| -- Install shell API
 | |
| function shell.run(...)
 | |
| 
 | |
|   local path, args = parseCommandLine(...)
 | |
|   local isUrl = not not path:match("^(https?:)//(([^/:]+):?([0-9]*))(/?.*)$")
 | |
| 
 | |
|   if not isUrl then
 | |
|     path = shell.resolveProgram(path)
 | |
|   end
 | |
| 
 | |
|   if path then
 | |
|     tProgramStack[#tProgramStack + 1] = path
 | |
|     local oldTitle
 | |
| 
 | |
|     if multishell and multishell.getTitle then
 | |
|       oldTitle = multishell.getTitle(multishell.getCurrent())
 | |
|       multishell.setTitle(multishell.getCurrent(), fs.getName(path))
 | |
|     end
 | |
| 
 | |
|     local result, err
 | |
| 
 | |
|     local env = Util.shallowCopy(sandboxEnv)
 | |
|     if isUrl then
 | |
|       result, err = Util.runUrl(env, path, unpack(args))
 | |
|     else
 | |
|       result, err = Util.run(env, path, unpack(args))
 | |
|     end
 | |
|     tProgramStack[#tProgramStack] = nil
 | |
| 
 | |
|     if multishell and multishell.getTitle then
 | |
|       multishell.setTitle(multishell.getCurrent(), oldTitle or 'shell')
 | |
|     end
 | |
| 
 | |
|     return result, err
 | |
|   end
 | |
|   return false, 'No such program'
 | |
| end
 | |
| 
 | |
| function shell.exit()
 | |
|   bExit = true
 | |
| end
 | |
| 
 | |
| function shell.dir() return DIR end
 | |
| function shell.setDir(d) DIR = d end
 | |
| function shell.path() return PATH end
 | |
| function shell.setPath(p) PATH = p end
 | |
| 
 | |
| function shell.resolve( _sPath )
 | |
|   local sStartChar = string.sub( _sPath, 1, 1 )
 | |
|   if sStartChar == "/" or sStartChar == "\\" then
 | |
|     return fs.combine( "", _sPath )
 | |
|   else
 | |
|     return fs.combine(DIR, _sPath )
 | |
|   end
 | |
| end
 | |
| 
 | |
| function shell.resolveProgram( _sCommand )
 | |
| 
 | |
|   if ALIASES[ _sCommand ] ~= nil then
 | |
|     _sCommand = ALIASES[ _sCommand ]
 | |
|   end
 | |
| 
 | |
|   local path = shell.resolve(_sCommand)
 | |
|   if fs.exists(path) and not fs.isDir(path) then
 | |
|     return path
 | |
|   end
 | |
|   if fs.exists(path .. '.lua') then
 | |
|     return path .. '.lua'
 | |
|   end
 | |
| 
 | |
|   -- If the path is a global path, use it directly
 | |
|   local sStartChar = string.sub( _sCommand, 1, 1 )
 | |
|   if sStartChar == "/" or sStartChar == "\\" then
 | |
|     local sPath = fs.combine( "", _sCommand )
 | |
|     if fs.exists( sPath ) and not fs.isDir( sPath ) then
 | |
|       return sPath
 | |
|     end
 | |
|     return nil
 | |
|   end
 | |
|   
 | |
|   -- Otherwise, look on the path variable
 | |
|   for sPath in string.gmatch(PATH or '', "[^:]+") do
 | |
|     sPath = fs.combine(sPath, _sCommand )
 | |
|     if fs.exists( sPath ) and not fs.isDir( sPath ) then
 | |
|       return sPath
 | |
|     end
 | |
|     if fs.exists(sPath .. '.lua') then
 | |
|       return sPath .. '.lua'
 | |
|     end
 | |
|   end
 | |
|   
 | |
|   -- Not found
 | |
|   return nil
 | |
| end
 | |
| 
 | |
| function shell.programs( _bIncludeHidden )
 | |
|   local tItems = {}
 | |
|   
 | |
|   -- Add programs from the path
 | |
|   for sPath in string.gmatch(PATH, "[^:]+") do
 | |
|     sPath = shell.resolve(sPath)
 | |
|     if fs.isDir( sPath ) then
 | |
|       local tList = fs.list( sPath )
 | |
|       for n,sFile in pairs( tList ) do
 | |
|         if not fs.isDir( fs.combine( sPath, sFile ) ) and
 | |
|           (_bIncludeHidden or string.sub( sFile, 1, 1 ) ~= ".") then
 | |
|           tItems[ sFile ] = true
 | |
|         end
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   -- Sort and return
 | |
|   local tItemList = {}
 | |
|   for sItem, b in pairs( tItems ) do
 | |
|     table.insert( tItemList, sItem )
 | |
|   end
 | |
|   table.sort( tItemList )
 | |
|   return tItemList
 | |
| end
 | |
| 
 | |
| function shell.complete(sLine) end
 | |
| function shell.completeProgram(sProgram) end
 | |
| 
 | |
| function shell.setCompletionFunction(sProgram, fnComplete)
 | |
|   tCompletionInfo[sProgram] = { fnComplete = fnComplete }
 | |
| end
 | |
| 
 | |
| function shell.getCompletionInfo()
 | |
|   return tCompletionInfo
 | |
| end
 | |
| 
 | |
| function shell.getRunningProgram()
 | |
|   return tProgramStack[#tProgramStack]
 | |
| end
 | |
| 
 | |
| function shell.setAlias( _sCommand, _sProgram )
 | |
|   ALIASES[ _sCommand ] = _sProgram
 | |
| end
 | |
| 
 | |
| function shell.clearAlias( _sCommand )
 | |
|   ALIASES[ _sCommand ] = nil
 | |
| end
 | |
| 
 | |
| function shell.aliases()
 | |
|   local tCopy = {}
 | |
|   for sAlias, sCommand in pairs(ALIASES) do
 | |
|     tCopy[sAlias] = sCommand
 | |
|   end
 | |
|   return tCopy
 | |
| end
 | |
| 
 | |
| function shell.newTab(tabInfo, ...)
 | |
|   local path, args = parseCommandLine(...)
 | |
|   path = shell.resolveProgram(path)
 | |
| 
 | |
|   if path then
 | |
|     tabInfo.path = path
 | |
|     tabInfo.env = sandboxEnv
 | |
|     tabInfo.args = Util.shallowCopy(args)
 | |
|     tabInfo.title = fs.getName(path)
 | |
| 
 | |
|     if path ~= 'sys/apps/shell' then
 | |
|       table.insert(tabInfo.args, 1, tabInfo.path)
 | |
|       tabInfo.path = 'sys/apps/shell'
 | |
|     end
 | |
|     return multishell.openTab(tabInfo)
 | |
|   end
 | |
|   return nil, 'No such program'
 | |
| end
 | |
| 
 | |
| function shell.openTab( ... )
 | |
|   return shell.newTab({ }, ...)
 | |
| end
 | |
| 
 | |
| function shell.openForegroundTab( ... )
 | |
|   return shell.newTab({ focused = true }, ...)
 | |
| end
 | |
| 
 | |
| function shell.openHiddenTab( ... )
 | |
|   return shell.newTab({ hidden = true }, ...)
 | |
| end
 | |
| 
 | |
| function shell.switchTab(tabId)
 | |
|   multishell.setFocus(tabId)
 | |
| end
 | |
| 
 | |
| local tArgs = { ... }
 | |
| if #tArgs > 0 then
 | |
| 
 | |
|   local path, args = parseCommandLine(...)
 | |
| 
 | |
|   if not path then
 | |
|     error('No such program')
 | |
|   end
 | |
| 
 | |
|   local isUrl = not not path:match("^(https?:)//(([^/:]+):?([0-9]*))(/?.*)$")
 | |
|   if not isUrl then
 | |
|     path = shell.resolveProgram(path)
 | |
|     if not path then
 | |
|       error('No such program')
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   local fn, err
 | |
| 
 | |
|   if isUrl then
 | |
|     fn, err = Util.loadUrl(path, getfenv(1))
 | |
|   else
 | |
|     fn, err = loadfile(path, getfenv(1))
 | |
|   end
 | |
| 
 | |
|   if not fn then
 | |
|     error(err)
 | |
|   end
 | |
| 
 | |
|   tProgramStack[#tProgramStack + 1] = path
 | |
|   return fn(table.unpack(args))
 | |
| end
 | |
| 
 | |
| local Config  = require('config')
 | |
| local History = require('history')
 | |
| 
 | |
| local config = {
 | |
|   standard = {
 | |
|     textColor  = colors.white,
 | |
|     commandTextColor = colors.lightGray,
 | |
|     directoryTextColor = colors.gray,
 | |
|     directoryBackgroundColor = colors.black,
 | |
|     promptTextColor = colors.gray,
 | |
|     promptBackgroundColor = colors.black,
 | |
|     directoryColor = colors.gray,
 | |
|   },
 | |
|   color = {
 | |
|     textColor = colors.white,
 | |
|     commandTextColor = colors.yellow,
 | |
|     directoryTextColor  = colors.orange,
 | |
|     directoryBackgroundColor = colors.black,
 | |
|     promptTextColor = colors.blue,
 | |
|     promptBackgroundColor = colors.black,
 | |
|     directoryColor = colors.green,
 | |
|   },
 | |
|   displayDirectory = true,
 | |
| }
 | |
| 
 | |
| --Config.load('shell', config)
 | |
| 
 | |
| local _colors = config.standard
 | |
| if term.isColor() then
 | |
|   _colors = config.color
 | |
| end
 | |
| 
 | |
| local function autocompleteFile(results, words)
 | |
| 
 | |
|   local function getBaseDir(path)
 | |
|     if #path > 1 then
 | |
|       if path:sub(-1) ~= '/' then
 | |
|         path = fs.getDir(path)
 | |
|       end
 | |
|     end
 | |
|     if path:sub(1, 1) == '/' then
 | |
|       path = fs.combine(path, '')
 | |
|     else
 | |
|       path = fs.combine(shell.dir(), path)
 | |
|     end
 | |
|     while not fs.isDir(path) do
 | |
|       path = fs.getDir(path)
 | |
|     end
 | |
|     return path
 | |
|   end
 | |
| 
 | |
|   local function getRawPath(path)
 | |
|     local baseDir = ''
 | |
|     if path:sub(1, 1) ~= '/' then
 | |
|       baseDir = shell.dir()
 | |
|     end
 | |
|     if #path > 1 then
 | |
|       if path:sub(-1) ~= '/' then
 | |
|         path = fs.getDir(path)
 | |
|       end
 | |
|     end
 | |
|     if fs.isDir(fs.combine(baseDir, path)) then
 | |
|       return path
 | |
|     end
 | |
|     return fs.getDir(path)
 | |
|   end
 | |
| 
 | |
|   local match = words[#words] or ''
 | |
|   local startDir = getBaseDir(match)
 | |
|   local rawPath = getRawPath(match)
 | |
| 
 | |
|   if fs.isDir(startDir) then
 | |
|     local files = fs.list(startDir)
 | |
|     for _,f in pairs(files) do
 | |
|       local path = fs.combine(rawPath, f)
 | |
|       if fs.isDir(fs.combine(startDir, f)) then
 | |
|         results[path .. '/'] = 'directory'
 | |
|       else
 | |
|         results[path .. ' '] = 'program'
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| end
 | |
| 
 | |
| local function autocompleteProgram(results, words)
 | |
|   if #words == 1 then
 | |
|     local files = shell.programs(true)
 | |
|     for _,f in ipairs(files) do
 | |
|       results[f .. ' '] = 'program'
 | |
|     end
 | |
|     for f in pairs(ALIASES) do
 | |
|       results[f .. ' '] = 'program'
 | |
|     end
 | |
|   end
 | |
| end
 | |
| 
 | |
| local function autocompleteArgument(results, program, words)
 | |
|   local word = ''
 | |
|   if #words > 1 then
 | |
|     word = words[#words]
 | |
|   end
 | |
| 
 | |
|   local tInfo = tCompletionInfo[program]
 | |
|   local args = tInfo.fnComplete(shell, #words - 1, word, words)
 | |
|   if args then
 | |
|     Util.filterInplace(args, function(f)
 | |
|       return not Util.key(args, f .. '/')
 | |
|     end)
 | |
|     for _,arg in ipairs(args) do
 | |
|       results[word .. arg] = 'argument'
 | |
|     end
 | |
|   end
 | |
| end
 | |
| 
 | |
| local function autocomplete(line, suggestions)
 | |
|   local words = { }
 | |
|   for word in line:gmatch("%S+") do
 | |
|     table.insert(words, word)
 | |
|   end
 | |
|   if line:match(' $') then
 | |
|     table.insert(words, '')
 | |
|   end
 | |
| 
 | |
|   local results = { }
 | |
| 
 | |
|   if #words == 0 then
 | |
|     files = autocompleteFile(results, words)
 | |
|   else
 | |
|     local program = shell.resolveProgram(words[1])
 | |
|     if tCompletionInfo[program] then
 | |
|       autocompleteArgument(results, program, words)
 | |
|     else
 | |
|       autocompleteProgram(results, words)
 | |
|       autocompleteFile(results, words)
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   local match = words[#words] or ''
 | |
|   local files = { }
 | |
|   for f in pairs(results) do
 | |
|     if f:sub(1, #match) == match then
 | |
|       table.insert(files, f)
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   if #files == 1 then
 | |
|     words[#words] = files[1] 
 | |
|     return table.concat(words, ' ')
 | |
|   elseif #files > 1 and suggestions then
 | |
|     print()
 | |
| 
 | |
|     local word = words[#words] or ''
 | |
|     local prefix = word:match("(.*/)") or ''
 | |
|     if #prefix > 0 then
 | |
|       for _,f in ipairs(files) do
 | |
|         if f:match("^" .. prefix) ~= prefix then
 | |
|           prefix = ''
 | |
|           break
 | |
|         end
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     local tDirs, tFiles = { }, { }
 | |
|     for _,f in ipairs(files) do
 | |
|       if results[f] == 'directory' then
 | |
|         f = f:gsub(prefix, '', 1)
 | |
|         table.insert(tDirs, f)
 | |
|       else
 | |
|         f = f:gsub(prefix, '', 1)
 | |
|         table.insert(tFiles, f)
 | |
|       end
 | |
|     end
 | |
|     table.sort(tDirs)
 | |
|     table.sort(tFiles)
 | |
| 
 | |
|     if #tDirs > 0 and #tDirs < #tFiles then
 | |
|       local w = term.getSize()
 | |
|       local nMaxLen = w / 8
 | |
|       for n, sItem in pairs(files) do
 | |
|         nMaxLen = math.max(string.len(sItem) + 1, nMaxLen)
 | |
|       end
 | |
|       local nCols = math.floor(w / nMaxLen)
 | |
|       if #tDirs < nCols then
 | |
|         for i = #tDirs + 1, nCols do
 | |
|           table.insert(tDirs, '')
 | |
|         end
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     if #tDirs > 0 then
 | |
|       textutils.tabulate(_colors.directoryColor, tDirs, colors.white, tFiles)
 | |
|     else
 | |
|       textutils.tabulate(colors.white, tFiles)
 | |
|     end
 | |
| 
 | |
|     term.setTextColour(_colors.promptTextColor)
 | |
|     term.setBackgroundColor(_colors.promptBackgroundColor)
 | |
|     write("$ " )
 | |
| 
 | |
|     term.setTextColour(_colors.commandTextColor)
 | |
|     term.setBackgroundColor(colors.black)
 | |
|     return line
 | |
|   elseif #files > 1 then
 | |
| 
 | |
|     -- ugly (complete as much as possible)
 | |
|     local word = words[#words] or ''
 | |
|     local i = #word + 1
 | |
|     while true do
 | |
|       local ch
 | |
|       for _,f in ipairs(files) do
 | |
|         if #f < i then
 | |
|           words[#words] = string.sub(f, 1, i - 1)
 | |
|           return table.concat(words, ' ')
 | |
|         end
 | |
|         if not ch then
 | |
|           ch = string.sub(f, i, i)
 | |
|         elseif string.sub(f, i, i) ~= ch then
 | |
|           if i == #word + 1 then
 | |
|             return
 | |
|           end
 | |
|           words[#words] = string.sub(f, 1, i - 1)
 | |
|           return table.concat(words, ' ')
 | |
|         end
 | |
|       end
 | |
|       i = i + 1
 | |
|     end
 | |
|   end
 | |
| end
 | |
| 
 | |
| local function shellRead(history)
 | |
|   term.setCursorBlink( true )
 | |
| 
 | |
|   local sLine = ""
 | |
|   local nPos = 0
 | |
|   local lastPattern
 | |
| 
 | |
|   local w = term.getSize()
 | |
|   local sx = term.getCursorPos()
 | |
| 
 | |
|   history:reset()
 | |
| 
 | |
|   local function redraw( sReplace )
 | |
|     local nScroll = 0
 | |
|     if sx + nPos >= w then
 | |
|       nScroll = (sx + nPos) - w
 | |
|     end
 | |
| 
 | |
|     local cx,cy = term.getCursorPos()
 | |
|     term.setCursorPos( sx, cy )
 | |
|     if sReplace then
 | |
|       term.write( string.rep( sReplace, math.max( string.len(sLine) - nScroll, 0 ) ) )
 | |
|     else
 | |
|       term.write( string.sub( sLine, nScroll + 1 ) )
 | |
|     end
 | |
|     term.setCursorPos( sx + nPos - nScroll, cy )
 | |
|   end
 | |
| 
 | |
|   while true do
 | |
|     local sEvent, param, param2 = os.pullEventRaw()
 | |
| 
 | |
|     if sEvent == "char" then
 | |
|       sLine = string.sub( sLine, 1, nPos ) .. param .. string.sub( sLine, nPos + 1 )
 | |
|       nPos = nPos + 1
 | |
|       redraw()
 | |
|     elseif sEvent == "paste" then
 | |
|       sLine = string.sub( sLine, 1, nPos ) .. param .. string.sub( sLine, nPos + 1 )
 | |
|       nPos = nPos + string.len( param )
 | |
|       redraw()
 | |
|     elseif sEvent == 'mouse_click' and param == 2 then
 | |
|       redraw(string.rep(' ', #sLine))
 | |
|       sLine = ''
 | |
|       nPos = 0
 | |
|       redraw()
 | |
|     elseif sEvent == 'terminate' then
 | |
|       bExit = true
 | |
|       break
 | |
|     elseif sEvent == "key" then
 | |
|       if param == keys.enter then
 | |
|         -- Enter
 | |
|         break
 | |
|       elseif param == keys.tab then
 | |
|         if nPos == #sLine then
 | |
|           local showSuggestions = lastPattern == sLine
 | |
|           lastPattern = sLine
 | |
| 
 | |
|           local cline = autocomplete(sLine, showSuggestions)
 | |
|           if cline then
 | |
|             sLine = cline
 | |
|             nPos = #sLine
 | |
|             redraw()
 | |
|           end
 | |
|         end
 | |
|       elseif param == keys.left then
 | |
|         if nPos > 0 then
 | |
|           nPos = nPos - 1
 | |
|           redraw()
 | |
|         end
 | |
|       elseif param == keys.right then
 | |
|         if nPos < string.len(sLine) then
 | |
|           redraw(" ")
 | |
|           nPos = nPos + 1
 | |
|           redraw()
 | |
|         end
 | |
|       elseif param == keys.up or param == keys.down then
 | |
|         redraw(" ")
 | |
|         if param == keys.up then
 | |
|           sLine = history:back()
 | |
|         else
 | |
|           sLine = history:forward()
 | |
|         end
 | |
|         if sLine then
 | |
|           nPos = string.len(sLine)
 | |
|         else
 | |
|           sLine = ""
 | |
|           nPos = 0
 | |
|         end
 | |
|         redraw()
 | |
|       elseif param == keys.backspace then
 | |
|         if nPos > 0 then
 | |
|           redraw(" ")
 | |
|           sLine = string.sub( sLine, 1, nPos - 1 ) .. string.sub( sLine, nPos + 1 )
 | |
|           nPos = nPos - 1                    
 | |
|           redraw()
 | |
|         end
 | |
|       elseif param == keys.home then
 | |
|         redraw(" ")
 | |
|         nPos = 0
 | |
|         redraw()
 | |
|       elseif param == keys.delete then
 | |
|         if nPos < string.len(sLine) then
 | |
|           redraw(" ")
 | |
|           sLine = string.sub( sLine, 1, nPos ) .. string.sub( sLine, nPos + 2 )
 | |
|           redraw()
 | |
|         end
 | |
|       elseif param == keys["end"] then
 | |
|         redraw(" ")
 | |
|         nPos = string.len(sLine)
 | |
|         redraw()
 | |
|       end
 | |
|     elseif sEvent == "term_resize" then
 | |
|       w = term.getSize()
 | |
|       redraw()
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   local cx, cy = term.getCursorPos()
 | |
|   term.setCursorPos( w + 1, cy )
 | |
|   print()
 | |
|   term.setCursorBlink( false )
 | |
|   return sLine
 | |
| end
 | |
| 
 | |
| local history = History.load('usr/.shell_history', 25)
 | |
| 
 | |
| while not bExit do
 | |
|   if config.displayDirectory then
 | |
|     term.setTextColour(_colors.directoryTextColor)
 | |
|     term.setBackgroundColor(_colors.directoryBackgroundColor)
 | |
|     print('==' .. os.getComputerLabel() .. ':/' .. DIR)
 | |
|   end
 | |
|   term.setTextColour(_colors.promptTextColor)
 | |
|   term.setBackgroundColor(_colors.promptBackgroundColor)
 | |
|   write("$ " )
 | |
|   term.setTextColour(_colors.commandTextColor)
 | |
|   term.setBackgroundColor(colors.black)
 | |
|   local sLine = shellRead(history)
 | |
|   if bExit then -- terminated
 | |
|     break
 | |
|   end
 | |
|   sLine = Util.trim(sLine)
 | |
|   if #sLine > 0 and sLine ~= 'exit' then
 | |
|     history:add(sLine)
 | |
|   end
 | |
|   term.setTextColour(_colors.textColor)
 | |
|   if #sLine > 0 then
 | |
|     local result, err = shell.run( sLine )
 | |
|     if not result and err then
 | |
|       printError(err)
 | |
|     end
 | |
|   end
 | |
| end | 
