-- libccpm: the testable core of the TrapOS package manager (ccpm). -- -- A factory: `local createCcpm = require('/apis/libccpm'); local ccpm = createCcpm();` -- -- State lives under `opts.stateDir` (default `/trapos`): -- - ccpm.json -> { registries = { { name, type, branch }, ... } } -- - ccpm.lock.json -> { packages = { = { version, registry, files, -- dependencies, autostart } } } -- - ccpm.cache.json -> { packages = { = { version, registry } } } -- -- Files are written under `opts.installRoot` (default '' -> filesystem root), so -- tests can sandbox downloads. `opts.http` overrides the global `http` for tests. local DEFAULT_STATE_DIR = '/trapos'; local function normalizePath(path) path = path:gsub('//+', '/'); if path:sub(1, 1) ~= '/' then path = '/' .. path; end return path; end local function parentDir(path) return path:match('^(.*)/[^/]+$'); end local function createCcpm(opts) opts = opts or {}; local httpLib = opts.http or http; local stateDir = opts.stateDir or DEFAULT_STATE_DIR; local installRoot = opts.installRoot or ''; local configPath = stateDir .. '/ccpm.json'; local lockPath = stateDir .. '/ccpm.lock.json'; local cachePath = stateDir .. '/ccpm.cache.json'; local manifestPath = stateDir .. '/manifest.json'; local api = {}; -- ---------- JSON file helpers ---------- local function readJsonFile(path) if not fs.exists(path) then return nil; end local f = fs.open(path, 'r'); if not f then return nil; end local data = f.readAll(); f.close(); if not data or data == '' then return nil; end return textutils.unserializeJSON(data); end local function writeJsonFile(path, value) local dir = parentDir(path); if dir then fs.makeDir(dir); end local f = fs.open(path, 'w'); if not f then return false; end f.write(textutils.serializeJSON(value)); f.close(); return true; end -- ---------- config (registries) ---------- function api.readConfig() return readJsonFile(configPath) or { registries = {} }; end function api.writeConfig(cfg) return writeJsonFile(configPath, cfg); end function api.listRegistries() return api.readConfig().registries or {}; end function api.addRegistry(name, registryOpts) registryOpts = registryOpts or {}; local cfg = api.readConfig(); cfg.registries = cfg.registries or {}; for _, r in ipairs(cfg.registries) do if r.name == name then return false, 'registry already exists: ' .. name; end end local registry = { name = name, type = registryOpts.type or 'gitea', branch = registryOpts.branch or 'master', }; cfg.registries[#cfg.registries + 1] = registry; api.writeConfig(cfg); return true, registry; end function api.removeRegistry(name) local cfg = api.readConfig(); cfg.registries = cfg.registries or {}; local kept = {}; local found = false; for _, r in ipairs(cfg.registries) do if r.name == name then found = true; else kept[#kept + 1] = r; end end if not found then return false, 'registry not found: ' .. name; end cfg.registries = kept; api.writeConfig(cfg); return true; end -- ---------- lock (installed packages) ---------- function api.readLock() return readJsonFile(lockPath) or { packages = {} }; end function api.writeLock(lock) return writeJsonFile(lockPath, lock); end function api.list() return api.readLock().packages or {}; end local function writeOsState(lock) local files = {}; local seenFile = {}; local autostart = {}; local seenAuto = {}; for _, entry in pairs(lock.packages or {}) do for _, filePath in ipairs(entry.files or {}) do if not seenFile[filePath] then seenFile[filePath] = true; files[#files + 1] = filePath; end end for _, server in ipairs(entry.autostart or {}) do if not seenAuto[server] then seenAuto[server] = true; autostart[#autostart + 1] = server; end end end table.sort(files); table.sort(autostart); local cfg = api.readConfig(); local registry = cfg.registries and cfg.registries[1] or {}; local trapos = lock.packages and lock.packages.trapos; return writeJsonFile(manifestPath, { name = 'TrapOS', version = trapos and trapos.version or '?', branch = registry.branch or 'master', files = files, autostart = autostart, }); end -- ---------- cache (available packages) ---------- function api.readCache() return readJsonFile(cachePath) or { packages = {} }; end function api.writeCache(cache) return writeJsonFile(cachePath, cache); end -- ---------- URL resolution ---------- function api.registryBaseUrl(registry) if registry.type == 'github' then local branch = registry.branch or 'master'; return 'https://raw.githubusercontent.com/' .. registry.name .. '/' .. branch .. '/'; elseif registry.type == 'gitea' then local branch = registry.branch or 'master'; return 'https://git.trapcloud.fr/' .. registry.name .. '/raw/branch/' .. branch .. '/'; end local base = registry.name; if base:sub(-1) ~= '/' then base = base .. '/'; end return base; end function api.descriptorUrl(registry, pkg) return api.registryBaseUrl(registry) .. 'packages/' .. pkg .. '/ccpm.json'; end function api.indexUrl(registry) return api.registryBaseUrl(registry) .. 'packages/index.json'; end function api.fileUrl(registry, filePath) return api.registryBaseUrl(registry) .. filePath; end -- ---------- HTTP ---------- local function httpGetBody(url) local res = httpLib.get(url); if not res then return nil; end local body = res.readAll(); res.close(); return body; end function api.fetchJson(url) local body = httpGetBody(url); if not body or body == '' then return nil; end return textutils.unserializeJSON(body); end -- Find a package descriptor across the configured registries. -- Returns descriptor, registry (or nil when not found). function api.findPackage(pkg) local cfg = api.readConfig(); for _, registry in ipairs(cfg.registries or {}) do local desc = api.fetchJson(api.descriptorUrl(registry, pkg)); if desc then return desc, registry; end end return nil; end function api.info(pkg) return api.findPackage(pkg); end function api.search(term) local results = {}; local cache = api.readCache(); for name, entry in pairs(cache.packages or {}) do if not term or term == '' or string.find(name, term, 1, true) then results[#results + 1] = { name = name, version = entry.version, registry = entry.registry }; end end table.sort(results, function(a, b) return a.name < b.name; end); return results; end function api.update() local cfg = api.readConfig(); local cache = { packages = {} }; for _, registry in ipairs(cfg.registries or {}) do local index = api.fetchJson(api.indexUrl(registry)); if index and index.packages then for name, version in pairs(index.packages) do if not cache.packages[name] then cache.packages[name] = { version = version, registry = registry.name }; end end end end api.writeCache(cache); return cache; end local function compareVersions(a, b) a = tostring(a or ''); b = tostring(b or ''); if a == b then return 0; end local aparts = {}; local bparts = {}; for part in string.gmatch(a, '[^%.]+') do aparts[#aparts + 1] = part; end for part in string.gmatch(b, '[^%.]+') do bparts[#bparts + 1] = part; end local max = math.max(#aparts, #bparts); local hitBreak = false; for i = 1, max do local apart = aparts[i] or '0'; local bpart = bparts[i] or '0'; local anum = tonumber(apart); local bnum = tonumber(bpart); if not anum or not bnum then hitBreak = true; break; end if anum < bnum then return -1; end if anum > bnum then return 1; end end if not hitBreak then return 0; end if a < b then return -1; end return 1; end function api.available(term) local cache = api.readCache(); local installed = api.list(); local results = {}; for name, entry in pairs(cache.packages or {}) do if not term or term == '' or string.find(name, term, 1, true) then local status = 'available'; local installedVersion = nil; if installed[name] then installedVersion = installed[name].version; if compareVersions(installedVersion, entry.version) < 0 then status = 'updatable'; else status = 'up-to-date'; end end results[#results + 1] = { name = name, version = entry.version, installedVersion = installedVersion, registry = entry.registry, status = status, }; end end table.sort(results, function(a, b) return a.name < b.name; end); return results; end -- ---------- dependency resolution ---------- -- Resolve `pkg` and all of its dependencies into install order (deps first, -- target last). Returns an ordered list of { name, desc, registry } or nil, err. function api.resolve(pkg) local ordered = {}; local state = {}; -- name -> 'visiting' | 'done' local errMsg = nil; local function visit(name, chain) if errMsg then return; end if state[name] == 'done' then return; end if state[name] == 'visiting' then errMsg = 'dependency cycle detected: ' .. table.concat(chain, ' -> ') .. ' -> ' .. name; return; end state[name] = 'visiting'; local desc, registry = api.findPackage(name); if not desc then errMsg = 'package not found: ' .. name; return; end chain[#chain + 1] = name; for _, dep in ipairs(desc.dependencies or {}) do visit(dep, chain); if errMsg then return; end end chain[#chain] = nil; state[name] = 'done'; ordered[#ordered + 1] = { name = name, desc = desc, registry = registry }; end visit(pkg, {}); if errMsg then return nil, errMsg; end return ordered; end -- ---------- install / uninstall ---------- local function installTarget(filePath) return normalizePath(installRoot .. '/' .. filePath); end function api.downloadFile(registry, filePath) local body = httpGetBody(api.fileUrl(registry, filePath)); if not body then return false, 'failed to download ' .. filePath; end local target = installTarget(filePath); local dir = parentDir(target); if dir then fs.makeDir(dir); end fs.delete(target); local f = fs.open(target, 'w'); if not f then return false, 'failed to write ' .. target; end f.write(body); f.close(); return true; end -- installOpts: { force = bool, log = function(msg) } function api.install(pkg, installOpts) installOpts = installOpts or {}; local log = installOpts.log or function() end; local lock = api.readLock(); lock.packages = lock.packages or {}; if lock.packages[pkg] and not installOpts.force then return false, "package already installed, use 'ccpm reinstall " .. pkg .. "' instead."; end local ordered, err = api.resolve(pkg); if not ordered then return false, err; end for _, item in ipairs(ordered) do local isTarget = item.name == pkg; local alreadyInstalled = lock.packages[item.name] ~= nil; if alreadyInstalled and not (isTarget and installOpts.force) then log('skip ' .. item.name .. ' (already installed)'); else log('install ' .. item.name .. ' v' .. tostring(item.desc.version or '?')); for _, filePath in ipairs(item.desc.files or {}) do log(' ' .. filePath); local ok, derr = api.downloadFile(item.registry, filePath); if not ok then return false, derr; end end lock.packages[item.name] = { version = item.desc.version, registry = item.registry.name, files = item.desc.files or {}, dependencies = item.desc.dependencies or {}, autostart = item.desc.autostart or {}, }; end end api.writeLock(lock); writeOsState(lock); return true, lock.packages[pkg]; end function api.upgrade(upgradeOpts) upgradeOpts = upgradeOpts or {}; local log = upgradeOpts.log or function() end; local lock = api.readLock(); local cache = api.readCache(); if not cache.packages or not next(cache.packages) then return false, "package cache is empty, run 'ccpm update' first."; end local names = {}; for name, entry in pairs(lock.packages or {}) do local cached = cache.packages[name]; if cached and compareVersions(entry.version, cached.version) < 0 then names[#names + 1] = name; end end table.sort(names); for _, name in ipairs(names) do log('upgrade ' .. name); local ok, err = api.install(name, { force = true, log = log }); if not ok then return false, err; end end return true, names; end -- uninstallOpts: { force = bool, log = function(msg) } function api.uninstall(pkg, uninstallOpts) uninstallOpts = uninstallOpts or {}; local log = uninstallOpts.log or function() end; local lock = api.readLock(); lock.packages = lock.packages or {}; local entry = lock.packages[pkg]; if not entry then return false, 'package not installed: ' .. pkg; end local dependents = {}; for name, e in pairs(lock.packages) do if name ~= pkg then for _, dep in ipairs(e.dependencies or {}) do if dep == pkg then dependents[#dependents + 1] = name; end end end end if #dependents > 0 and not uninstallOpts.force then table.sort(dependents); return false, 'cannot uninstall ' .. pkg .. ': required by ' .. table.concat(dependents, ', '); end for _, filePath in ipairs(entry.files or {}) do log('remove ' .. filePath); fs.delete(installTarget(filePath)); end lock.packages[pkg] = nil; api.writeLock(lock); writeOsState(lock); return true; end return api; end return createCcpm;