-- -- ComputerCraft Mekanism SCADA System Installer Utility -- --[[ Copyright © 2023 Mikayla Fischler 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. 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. ]]-- local function println(message) print(tostring(message)) end local function print(message) term.write(tostring(message)) end local VERSION = "v0.9g" local install_dir = "/.install-cache" local repo_path = "http://raw.githubusercontent.com/MikaylaFischler/cc-mek-scada/" local opts = { ... } local mode = nil local app = nil -- record the local installation manifest ---@param manifest table ---@param dependencies table local function write_install_manifest(manifest, dependencies) local versions = {} for key, value in pairs(manifest.versions) do local is_dependency = false for _, dependency in pairs(dependencies) do if key == "bootloader" and dependency == "system" then is_dependency = true break end end if key == app or key == "comms" or is_dependency then versions[key] = value end end manifest.versions = versions local imfile = fs.open("install_manifest.json", "w") imfile.write(textutils.serializeJSON(manifest)) imfile.close() end -- -- get and validate command line options -- println("-- CC Mekanism SCADA Installer " .. VERSION .. " --") if #opts == 0 or opts[1] == "help" then println("usage: ccmsi ") println("") term.setTextColor(colors.lightGray) println(" check - check latest versions avilable") term.setTextColor(colors.yellow) println(" ccmsi check for target") term.setTextColor(colors.lightGray) println(" install - fresh install, overwrites config") println(" update - update files EXCEPT for config/logs") println(" remove - delete files EXCEPT for config/logs") println(" purge - delete files INCLUDING config/logs") term.setTextColor(colors.white) println("") term.setTextColor(colors.lightGray) println(" reactor-plc - reactor PLC firmware") println(" rtu - RTU firmware") println(" supervisor - supervisor server application") println(" coordinator - coordinator application") println(" pocket - pocket application") term.setTextColor(colors.white) println("") term.setTextColor(colors.yellow) println(" second parameter when used with check") term.setTextColor(colors.lightGray) println(" note: defaults to main") println(" target GitHub tag or branch name") return else for _, v in pairs({ "check", "install", "update", "remove", "purge" }) do if opts[1] == v then mode = v break end end if mode == nil then println("unrecognized mode") return end for _, v in pairs({ "reactor-plc", "rtu", "supervisor", "coordinator", "pocket" }) do if opts[2] == v then app = v break end end if app == nil and mode ~= "check" then println("unrecognized application") return end end -- -- run selected mode -- if mode == "check" then ------------------------- -- GET REMOTE MANIFEST -- ------------------------- if opts[2] then repo_path = repo_path .. opts[2] .. "/" else repo_path = repo_path .. "main/" end local install_manifest = repo_path .. "install_manifest.json" local response, error = http.get(install_manifest) if response == nil then term.setTextColor(colors.orange) println("failed to get installation manifest from GitHub, cannot update or install") term.setTextColor(colors.red) println("HTTP error: " .. error) term.setTextColor(colors.white) return end local ok, manifest = pcall(function () return textutils.unserializeJSON(response.readAll()) end) if not ok then term.setTextColor(colors.red) println("error parsing remote installation manifest") term.setTextColor(colors.white) return end ------------------------ -- GET LOCAL MANIFEST -- ------------------------ local imfile = fs.open("install_manifest.json", "r") local local_ok = false local local_manifest = {} if imfile ~= nil then local_ok, local_manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end) imfile.close() end if not local_ok then term.setTextColor(colors.yellow) println("failed to load local installation information") term.setTextColor(colors.white) end -- list all versions for key, value in pairs(manifest.versions) do term.setTextColor(colors.purple) print(string.format("%-14s", "[" .. key .. "]")) if local_ok and (local_manifest.versions[key] ~= nil) then term.setTextColor(colors.blue) print(local_manifest.versions[key]) if value ~= local_manifest.versions[key] then term.setTextColor(colors.white) print(" (") term.setTextColor(colors.cyan) print(value) term.setTextColor(colors.white) println(" available)") else term.setTextColor(colors.green) println(" (up to date)") end else term.setTextColor(colors.lightGray) print("not installed") term.setTextColor(colors.white) print(" (latest ") term.setTextColor(colors.cyan) print(value) term.setTextColor(colors.white) println(")") end end elseif mode == "install" or mode == "update" then ------------------------- -- GET REMOTE MANIFEST -- ------------------------- if opts[3] then repo_path = repo_path .. opts[3] .. "/" else repo_path = repo_path .. "main/" end local install_manifest = repo_path .. "install_manifest.json" local response, error = http.get(install_manifest) if response == nil then term.setTextColor(colors.orange) println("failed to get installation manifest from GitHub, cannot update or install") term.setTextColor(colors.red) println("HTTP error: " .. error) term.setTextColor(colors.white) return end local ok, manifest = pcall(function () return textutils.unserializeJSON(response.readAll()) end) if not ok then term.setTextColor(colors.red) println("error parsing remote installation manifest") term.setTextColor(colors.white) end ------------------------ -- GET LOCAL MANIFEST -- ------------------------ local imfile = fs.open("install_manifest.json", "r") local local_ok = false local local_manifest = {} if imfile ~= nil then local_ok, local_manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end) imfile.close() end local local_app_version = nil local local_comms_version = nil local local_boot_version = nil -- try to find local versions if not local_ok then if mode == "update" then term.setTextColor(colors.red) println("failed to load local installation information, cannot update") term.setTextColor(colors.white) return end else local_app_version = local_manifest.versions[app] local_comms_version = local_manifest.versions.comms local_boot_version = local_manifest.versions.bootloader if local_manifest.versions[app] == nil then term.setTextColor(colors.red) println("another application is already installed, please purge it before installing a new application") term.setTextColor(colors.white) return end end local remote_app_version = manifest.versions[app] local remote_comms_version = manifest.versions.comms local remote_boot_version = manifest.versions.bootloader term.setTextColor(colors.green) if mode == "install" then println("installing " .. app .. " files...") elseif mode == "update" then println("updating " .. app .. " files... (keeping old config.lua)") end term.setTextColor(colors.white) -- display bootloader version change information if local_boot_version ~= nil then if local_boot_version ~= remote_boot_version then print("[bootldr] updating ") term.setTextColor(colors.blue) print(local_boot_version) term.setTextColor(colors.white) print(" \xbb ") term.setTextColor(colors.blue) println(remote_boot_version) term.setTextColor(colors.white) elseif mode == "install" then print("[bootldr] reinstalling ") term.setTextColor(colors.blue) println(local_boot_version) term.setTextColor(colors.white) end else print("[bootldr] new install of ") term.setTextColor(colors.blue) println(remote_boot_version) term.setTextColor(colors.white) end -- display app version change information if local_app_version ~= nil then if local_app_version ~= remote_app_version then print("[" .. app .. "] updating ") term.setTextColor(colors.blue) print(local_app_version) term.setTextColor(colors.white) print(" \xbb ") term.setTextColor(colors.blue) println(remote_app_version) term.setTextColor(colors.white) elseif mode == "install" then print("[" .. app .. "] reinstalling ") term.setTextColor(colors.blue) println(local_app_version) term.setTextColor(colors.white) end else print("[" .. app .. "] new install of ") term.setTextColor(colors.blue) println(remote_app_version) term.setTextColor(colors.white) end -- display comms version change information if local_comms_version ~= nil then if local_comms_version ~= remote_comms_version then print("[comms] updating ") term.setTextColor(colors.blue) print(local_comms_version) term.setTextColor(colors.white) print(" \xbb ") term.setTextColor(colors.blue) println(remote_comms_version) term.setTextColor(colors.white) print("[comms] ") term.setTextColor(colors.yellow) println("other devices on the network will require an update") term.setTextColor(colors.white) elseif mode == "install" then print("[comms] reinstalling ") term.setTextColor(colors.blue) println(local_comms_version) term.setTextColor(colors.white) end else print("[comms] new install of ") term.setTextColor(colors.blue) println(remote_comms_version) term.setTextColor(colors.white) end -------------------------- -- START INSTALL/UPDATE -- -------------------------- local space_required = manifest.sizes.manifest local space_available = fs.getFreeSpace("/") local single_file_mode = false local file_list = manifest.files local size_list = manifest.sizes local dependencies = manifest.depends[app] local config_file = app .. "/config.lua" table.insert(dependencies, app) for _, dependency in pairs(dependencies) do local size = size_list[dependency] space_required = space_required + size end -- check space constraints if space_available < space_required then single_file_mode = true term.setTextColor(colors.yellow) println("WARNING: Insufficient space available for a full download!") term.setTextColor(colors.white) println("Files can be downloaded one by one, so if you are replacing a current install this will not be a problem unless installation fails.") println("Do you wish to continue? (y/N)") local confirm = read() if confirm ~= "y" and confirm ~= "Y" then println("installation cancelled") return end end ---@diagnostic disable-next-line: undefined-field os.sleep(2) local success = true if not single_file_mode then if fs.exists(install_dir) then fs.delete(install_dir) fs.makeDir(install_dir) end -- download all dependencies for _, dependency in pairs(dependencies) do if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then -- skip system package if unchanged, skip app package if not changed -- skip packages that have no version if app version didn't change term.setTextColor(colors.white) print("skipping download of unchanged package ") term.setTextColor(colors.blue) println(dependency) else term.setTextColor(colors.white) print("downloading package ") term.setTextColor(colors.blue) println(dependency) term.setTextColor(colors.lightGray) local files = file_list[dependency] for _, file in pairs(files) do println("GET " .. file) local dl, err = http.get(repo_path .. file) if dl == nil then term.setTextColor(colors.red) println("GET HTTP Error " .. err) success = false break else local handle = fs.open(install_dir .. "/" .. file, "w") handle.write(dl.readAll()) handle.close() end end end end -- copy in downloaded files (installation) if success then for _, dependency in pairs(dependencies) do if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then -- skip system package if unchanged, skip app package if not changed -- skip packages that have no version if app version didn't change term.setTextColor(colors.white) print("skipping install of unchanged package ") term.setTextColor(colors.blue) println(dependency) else term.setTextColor(colors.white) print("installing package ") term.setTextColor(colors.blue) println(dependency) term.setTextColor(colors.lightGray) local files = file_list[dependency] for _, file in pairs(files) do if mode == "install" or file ~= config_file then local temp_file = install_dir .. "/" .. file if fs.exists(file) then fs.delete(file) end fs.move(temp_file, file) end end end end end fs.delete(install_dir) if success then -- if we made it here, then none of the file system functions threw exceptions -- that means everything is OK write_install_manifest(manifest, dependencies) term.setTextColor(colors.green) if mode == "install" then println("installation completed successfully") else println("update completed successfully") end else if mode == "install" then term.setTextColor(colors.red) println("installation failed") else term.setTextColor(colors.orange) println("update failed, existing files unmodified") end end else -- go through all files and replace one by one for _, dependency in pairs(dependencies) do if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then -- skip system package if unchanged, skip app package if not changed -- skip packages that have no version if app version didn't change term.setTextColor(colors.white) print("skipping install of unchanged package ") term.setTextColor(colors.blue) println(dependency) else term.setTextColor(colors.white) print("installing package ") term.setTextColor(colors.blue) println(dependency) term.setTextColor(colors.lightGray) local files = file_list[dependency] for _, file in pairs(files) do println("GET " .. file) local dl, err = http.get(repo_path .. file) if dl == nil then println("GET HTTP Error " .. err) success = false break else local handle = fs.open("/" .. file, "w") handle.write(dl.readAll()) handle.close() end end end end if success then -- if we made it here, then none of the file system functions threw exceptions -- that means everything is OK write_install_manifest(manifest, dependencies) term.setTextColor(colors.green) if mode == "install" then println("installation completed successfully") else println("update completed successfully") end else term.setTextColor(colors.red) if mode == "install" then println("installation failed, files may have been skipped") else println("update failed, files may have been skipped") end end end elseif mode == "remove" or mode == "purge" then local imfile = fs.open("install_manifest.json", "r") local ok = false local manifest = {} if imfile ~= nil then ok, manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end) imfile.close() end if not ok then term.setTextColor(colors.red) println("error parsing local installation manifest") term.setTextColor(colors.white) return elseif mode == "remove" and manifest.versions[app] == nil then term.setTextColor(colors.red) println(app .. " is not installed") term.setTextColor(colors.white) return end term.setTextColor(colors.orange) if mode == "remove" then println("removing all " .. app .. " files except for config.lua and log.txt...") elseif mode == "purge" then println("purging all " .. app .. " files...") end ---@diagnostic disable-next-line: undefined-field os.sleep(2) local file_list = manifest.files local dependencies = manifest.depends[app] local config_file = app .. "/config.lua" table.insert(dependencies, app) term.setTextColor(colors.lightGray) -- delete log file if purging if mode == "purge" and fs.exists(config_file) then local log_deleted = pcall(function () local config = require(app .. ".config") if fs.exists(config.LOG_PATH) then fs.delete(config.LOG_PATH) println("deleted log file " .. config.LOG_PATH) end end) if not log_deleted then term.setTextColor(colors.red) println("failed to delete log file") term.setTextColor(colors.lightGray) ---@diagnostic disable-next-line: undefined-field os.sleep(1) end end -- delete all files except config unless purging for _, dependency in pairs(dependencies) do local files = file_list[dependency] for _, file in pairs(files) do if mode == "purge" or file ~= config_file then if fs.exists(file) then fs.delete(file) println("deleted " .. file) end end end -- delete folders that we should be deleteing if mode == "purge" or dependency ~= app then local folder = files[1] while true do local dir = fs.getDir(folder) if dir == "" or dir == ".." then break else folder = dir end end if fs.isDir(folder) then fs.delete(folder) println("deleted directory " .. folder) end elseif dependency == app then for _, folder in pairs(files) do while true do local dir = fs.getDir(folder) if dir == "" or dir == ".." or dir == app then break else folder = dir end end if folder ~= app and fs.isDir(folder) then fs.delete(folder) println("deleted app subdirectory " .. folder) end end end end -- only delete manifest if purging if mode == "purge" then fs.delete("install_manifest.json") println("deleted install_manifest.json") else -- remove all data from versions list to show nothing is installed manifest.versions = {} imfile = fs.open("install_manifest.json", "w") imfile.write(textutils.serializeJSON(manifest)) imfile.close() end term.setTextColor(colors.green) println("done!") end term.setTextColor(colors.white)