-- -- pacmine.lua -- -- Copyright (c) 2026 Hilest -- -- Permission is hereby granted, free of charge, to any person obtaining a copy of -- this software and associated documentation files (the "Software"), to deal in -- the Software without restriction, including without limitation the rights to -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -- of the Software, and to permit persons to whom the Software is furnished to do -- so, subject to the following conditions: -- -- The above copyright notice and this permission notice shall be included in all -- copies or substantial portions of the Software. -- -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -- SOFTWARE. -- DEBUG = false -- Can be set to true to get debug messages and tracebacks json = require("lib.json") VERIFY_DIRS = {"/pacmine", "/pacmine/downloads", "/pacmine/packages"} -- Will be verified on existiance and being folders VERIFY_FILES = {"/pacmine/repos.json", "/pacmine/packages/pacmine.json"} -- Will be verified on existiance and being correct json-formated files INSTALLATION_BROKEN_MESSAGE = [[ pacmine installation seems broken and won't start. Consider fixing installation or reinstalling it. Failure cause: %s ]] HELP_MESSAGE = [[ Subcommands: help - Prints this message install package1 package2 ... packageN - Installs specified package(s) update package1 package2 ... packageN - Updates specified package(s) (if no packages specified, updates all outdated packages) remove package1 package2 ... packageN - Removes specified package(s), but does nothing with their dependencies clear package1 package2 ... packageN - Clears download cache of specified package(s), clears all download cache, if not specified ]] function table.slice(t, start, finish) start = start or 1 finish = finish or #t local result = {} for i = start, finish do table.insert(result, t[i]) end return result end function table.copy(t) return json.decode(json.encode(t)) end function concatArrays(arr1, arr2) ret = table.copy(arr1) arr2 = table.copy(arr2) for _, value in pairs(arr2) do ret[#ret + 1] = value end return ret end function debugPrint(msg) if DEBUG then print("[DEBUG] "..msg) end end function debugPause(msg) if DEBUG then print(string.format("[DEBUG] %s\nPress any key to continue", msg)) while true do event, key, held = os.pullEvent("key") if not held then break end end end end function confirm(msg) print(msg) while true do event, key, held = os.pullEvent("key") if key == keys.y and not held then return true elseif key == keys.n and not held then return false end end end function repoSelect(repos) print("This package is avaliable in multiple repositories:") for i, repo in pairs(repos) do print(i..": "..repo) end while true do write("Select repo: ") selected = tonumber(read()) if repos[selected] then return repos[selected] end end end function getFile(file, repo, printErrors) printErrors = printErrors or false url = string.format("https://%s/%s", repo, file) debugPrint("GET "..url) response = http.get(url) if not response then if printErrors then print("Failed to get file") end debugPrint("Response is nil") return end code = response.getResponseCode() if code == 200 then return response.readAll() else if printErrors then print(string.format("Failed to get file %s: resource %s returned code %d", file, url, code)) end debugPrint(code.." "..response.readAll()) return end response.close() end function getPackageIndex(package, repo) file = getFile(package.."/"..package..".json", repo) if not file then return else return json.decode(file) end end function isPackageInstalled(package) return fs.exists("/pacmine/packages/"..package..".json") and not fs.isDir("/pacmine/packages/"..package..".json") end function getLocalPackageIndex(package) path = "/pacmine/packages/"..package..".json" if not isPackageInstalled(package) then return else f = fs.open(path, "r") ret = json.decode(f.readAll()) f.close() return ret end end function listLocalPackages() pl = fs.list("/pacmine/packages") ret = {} for _, package in pairs(pl) do if getLocalPackageIndex(package) then ret[#ret + 1] = package end end debugPrint("Listing installed packages: "..json.encode(ret)) return ret end function packageLookup(packageName) f = fs.open("/pacmine/repos.json", "r") repos = json.decode(f.readAll()) ret = {} f.close() debugPrint(json.encode(repos)) if #repos == 0 then print("No repositories in /pacmine/repos.json\nAdd some repositories to lookup and download packages from") return end for _, repo in pairs(repos) do i = getPackageIndex(packageName, repo) if i then ret[#ret + 1] = repo end end return ret end function dependencyLookup(package, repo, resolvedPackages) -- TODO Dependencies in different repos debugPause("Checking dependencies of "..package.."@"..repo) local pckgs = {} local ret = {} local depsForCheck = {} for _, packageIndex in pairs(resolvedPackages) do pckgs[#pckgs + 1] = packageIndex.name.."@"..packageIndex.repo end debugPrint("pckgs:\n"..textutils.serialize(pckgs)) local index = getPackageIndex(package, repo) if not index then error("Package index is nil") end local deps = index.dependencies debugPause(package.." dependencies:\n"..json.encode(deps)) for _, dependency in pairs(deps) do local searchString = dependency.."@"..repo local found = false for _, pkg in pairs(pckgs) do if searchString == searchString then debugPrint(dependency.." found in pckgs") local found = true break end end depsForCheck[#depsForCheck + 1] = getPackageIndex(dependency, repo) if not found then debugPrint(dependency.." not found in resolveds, adding it to ret") ret[#ret + 1] = getPackageIndex(dependency, repo) resolvedPackages[#resolvedPackages + 1] = getPackageIndex(dependency, repo) end end resolvedPackages[#resolvedPackages + 1] = index for _, checkDependency in pairs(depsForCheck) do debugPause("Running dependencyLookup recursively for "..checkDependency.name) local depsOfDep = dependencyLookup(checkDependency.name, repo, resolvedPackages) for _, dep in pairs(depsOfDep) do ret[#ret + 1] = dep end end debugPause("Dependency check for "..package.."@"..repo.." complete. Returning "..json.encode(ret)) return ret end function clearCache(package) if fs.exists("/pacmine/downloads/"..package) then fs.delete("/pacmine/downloads/"..package) end end function downloadPackage(package, repo) index = getPackageIndex(package, repo) isCached = false isParticualyCached = false if fs.isDir("/pacmine/downloads/"..index.name) then isCached = true for repoFile, _ in pairs(index.files) do if not fs.exists("/pacmine/downloads/"..index.name.."/"..repoFile) then isParticualyCached = true break end end end if isCached and not isParticualyCached then print("Using cached "..package) return true end clearCache(package) print("Downloading package "..package) for repoFile, _ in pairs(index.files) do print("Downloading file "..repoFile) path = "/pacmine/downloads/"..index.name.."/"..repoFile debugPrint("Saving to "..path) f = fs.open(path, "w") content = getFile(index.name.."/"..repoFile, index.repo, true) if not content then print("Download failed") return false end f.write(content) f.close() end print(package.." download complete") return true end function removePackage(packageIndex) print("Removing package "..packageIndex.name) for _, localPath in pairs(packageIndex.files) do print("Deleting file "..localPath) fs.delete(localPath) end fs.delete("/pacmine/packages/"..packageIndex.name..".json") print(packageIndex.name.." removed") end function installPackage(packageIndex) ok = downloadPackage(packageIndex.name, packageIndex.repo) if not ok then print("Failed to install "..packageIndex.name) return false end print("Installing package "..packageIndex.name) for repoFile, localFile in pairs(packageIndex.files) do cachePath = "/pacmine/downloads/"..packageIndex.name.."/"..repoFile debugPause("Filesystem check") copyingAllowed = true if fs.exists(localFile) then result = confirm(string.format("File %s exists, replace? (y/n)", localFile)) if result then fs.delete(localFile) else copyingAllowed = false end end if copyingAllowed then fs.copy(cachePath, localFile) end end f = fs.open("/pacmine/packages/"..packageIndex.name..".json", "w") f.write(json.encode(packageIndex)) f.close() print("Installation of "..packageIndex.name.." complete") return true end function reinstallPackage(package, repo, msg) if msg == nil then msg = true end if msg then print("Reinstalling "..package) end if repo then clearCache(package) end repo = repo or getLocalPackageIndex(package).repo removePackage(getLocalPackageIndex(package)) installPackage(getPackageIndex(package, repo)) end function updatePackage(package, repo) print("Updating "..package) reinstallPackage(package, repo, false) end -- pacmine installation verification debugPrint("Verifying installation") for _, dir in pairs(VERIFY_DIRS) do debugPause("Verifying directory \""..dir.."\"...") if not fs.exists(dir) then print(string.format(INSTALLATION_BROKEN_MESSAGE, "object "..dir.." not exists")) debugPrint(debug.traceback("Traceback:")) return end if not fs.isDir(dir) then print(string.format(INSTALLATION_BROKEN_MESSAGE, "object "..dir.." is a file")) debugPrint(debug.traceback("Traceback:")) return end debugPrint("...OK") end for _, file in pairs(VERIFY_FILES) do debugPause("Verifying file \""..file.."\"...") ok, fileOrError = pcall(fs.open, file, "r") if not ok then print(string.format(INSTALLATION_BROKEN_MESSAGE, "failed to open "..file.." due to following error: "..fileOrError)) debugPrint(debug.traceback("Traceback:")) return end if not fileOrError then print(string.format(INSTALLATION_BROKEN_MESSAGE, "object "..file.." not exists")) debugPrint(debug.traceback("Traceback:")) return end content = fileOrError.readAll() fileOrError.close() ok, resultOrError = pcall(json.decode, content) if not ok then print(string.format(INSTALLATION_BROKEN_MESSAGE, "failed to parse JSON from "..file..": "..resultOrError)) debugPrint(debug.traceback("Traceback:")) return end fileOrError = nil resultOrError = nil debugPrint("...OK") end debugPause("Installation verification complete") if arg[1] == "help" then print(HELP_MESSAGE) return elseif arg[1] == "install" then print("Looking for matches...") packagesToInstall = {} packagesToReinstall = {} packagesToUpdate = {} for _, package in pairs(table.slice(arg, 2)) do debugPause("Processing "..package) skip = false action = 0 repos = packageLookup(package) if repos == nil then return end if #repos == 0 then print("Target not found: "..package) skip = true elseif #repos > 1 then repo = repoSelect(reos) else repo = repos[1] end remoteIndex = getPackageIndex(package, repo) -- Package check if isPackageInstalled(package) and not skip then localIndex = getLocalPackageIndex(package) if repo ~= localIndex.repo then result = confirm("You have this package installed from different repository\nIf you are sure, that this repository has the same package, thatm repository this package previously installed from, you can answer yes, otherwise it's recomended to answer no\nDo you want to continue? (y/n)") skip = not result end if not skip then if remoteIndex.version > localIndex.version then print("Repository has this package newer, than installed one. It will be updated") action = 2 else print("Package version provided by repository or newer is installed, reinstalling") action = 1 end end end -- Dependency lookup if not skip then print("Resolving dependencies for "..package) debugPause("Running dependency lookup for "..package) ok, errorOrResult = pcall(dependencyLookup, package, repo, table.copy(concatArrays(concatArrays(packagesToInstall, packagesToUpdate), packagesToReinstall))) if not ok then print("Dependency lookup failed for package "..package..", skipping") skip = true debugPrint(errorOrResult) end if not skip then for _, dependency in pairs(errorOrResult) do if not isPackageInstalled(dependency.name) then packagesToInstall[#packagesToInstall + 1] = dependency elseif dependency.version > getLocalPackageIndex(dependency.name) then print("Repository has this package newer, than installed one. It will be updated") packagesToUpdate[#packagesToUpdate + 1] = dependency end end end end debugPause("packagesToInstall is about to change, now:\n"..json.encode(packagesToInstall)) if not skip then if action == 0 then packagesToInstall[#packagesToInstall + 1] = remoteIndex elseif action == 1 then packagesToReinstall[#packagesToReinstall + 1] = remoteIndex elseif action == 2 then packagesToUpdate[#packagesToUpdate + 1] = remoteIndex end end end -- TODO Conflict check if #packagesToInstall == 0 and #packagesToUpdate == 0 and #packagesToReinstall == 0 then print("Nothing to do") return end write("Packages to install: ") for _, packageIndex in pairs(packagesToInstall) do write(packageIndex.name.." ") end write("\n") write("Packages to reinstall: ") for _, packageIndex in pairs(packagesToReinstall) do write(packageIndex.name.." ") end write("\n") write("Packages to update: ") for _, packageIndex in pairs(packagesToUpdate) do write(packageIndex.name.." ") end write("\n") result = confirm("Do you want to proceed? (y/n)") if not result then print("Aborted") return end for _, packageIndex in pairs(packagesToInstall) do installPackage(packageIndex) end for _, packageIndex in pairs(packagesToUpdate) do updatePackage(packageIndex.name, packageIndex.repo) end for _, packageIndex in pairs(packagesToReinstall) do reinstallPackage(packageIndex.name) end print("Complete") elseif arg[1] == "update" then if #table.slice(arg, 2) > 0 then print("Looking for matches...") packages = table.slice(arg, 2) else print("Starting full update...") packages = listLocalPackages() end packagesToInstall = {} packagesToUpdate = {} for _, package in pairs(table.slice(arg, 2)) do debugPause("Processing "..package) skip = false packageLocalIndex = getLocalPackageIndex(package) if not packageLocalIndex then print("Target not installed: "..package) skip = true end if not skip then repos = packageLookup(package) if repos == nil then return end if #repos == 0 then print("Target not found: "..package) skip = true elseif #repos > 1 then repo = repoSelect(repos) else repo = repos[1] end if not skip then remoteIndex = getPackageIndex(package, repo) end if repo ~= packageLocalIndex.repo and not skip then result = confirm("You have this package installed from different repository\nIf you are sure, that this repository has the same package, thatm repository this package previously installed from, you can answer yes, otherwise it's recomended to answer no\nDo you want to continue? (y/n)") skip = not result end end -- Dependency lookup if not skip then print("Resolving dependencies for "..package) debugPause("Running dependency lookup for "..package) ok, errorOrResult = pcall(dependencyLookup, package, repo, table.copy(concatArrays(packagesToInstall, packagesToUpdate))) if not ok then print("Dependency lookup failed for package "..package..", skipping") skip = true debugPrint(errorOrResult) end end if not skip then for _, dependency in pairs(errorOrResult) do if not isPackageInstalled(dependency.name) then packagesToInstall[#packagesToInstall + 1] = dependency elseif dependency.version > getLocalPackageIndex(dependency.name).version then packagesToUpdate[#packagesToUpdate + 1] = dependency end end end if not skip then packagesToUpdate[#packagesToUpdate + 1] = remoteIndex end end -- TODO Conflict check if #packagesToInstall == 0 and #packagesToUpdate == 0 then print("Nothing to do") return end write("Packages to install: ") for _, packageIndex in pairs(packagesToInstall) do write(packageIndex.name.." ") end write("\n") write("Packages to update: ") for _, packageIndex in pairs(packagesToUpdate) do write(packageIndex.name.." ") end write("\n") result = confirm("Do you want to proceed? (y/n)") if not result then print("Aborted") return end for _, packageIndex in pairs(packagesToInstall) do installPackage(packageIndex) end for _, packageIndex in pairs(packagesToUpdate) do updatePackage(packageIndex.name, packageIndex.repo) end print("Complete") elseif arg[1] == "remove" then print("Looking for matches...") packagesToUninstall = {} for _, package in pairs(table.slice(arg, 2)) do packageIndex = getLocalPackageIndex(package) if not packageIndex then print(string.format("Package %s not installed", package)) else packagesToUninstall[#packagesToUninstall + 1] = packageIndex end end if #packagesToUninstall == 0 then print("Nothing to do") return end write("Packages to remove: ") for _, packageRepoPair in pairs(packagesToUninstall) do write(packageRepoPair.name.." ") end write("\n") result = confirm("Do you want to proceed? (y/n)") if not result then print("Aborted") return end for _, packageIndex in pairs(packagesToUninstall) do removePackage(packageIndex) end print("Complete") -- elseif arg[1] == "purge" then -- TODO Purge package elseif arg[1] == "clear" then clearCacheArgs = table.slice(arg, 2) if #clearCacheArgs == 0 then print("Cleaning all download cache...") for _, cache in pairs(fs.list("/pacmine/downloads")) do clearCache(cache) end print("Complete") return end for _, cache in pairs(clearCacheArgs) do if fs.exists("/pacmine/downloads/"..cache) then clearCache(cache) end end print("Complete") else print("Wrong subcommand") print(HELP_MESSAGE) return end