local _VERSION = '0.1.0'; -- 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 } } } -- -- 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 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 'github', 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 -- ---------- 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 .. '/'; 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 cfg = api.readConfig(); local results = {}; 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 term or term == '' or string.find(name, term, 1, true) then results[#results + 1] = { name = name, version = version, registry = registry.name }; end end 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); return true, lock.packages[pkg]; 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); return true; end function api.version() return _VERSION; end return api; end return createCcpm;