cc-libs/apis/libccpm.lua

483 lines
14 KiB
Lua

local _VERSION = '0.2.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 = { <name> = { version, registry, files,
-- dependencies, autostart } } }
-- - ccpm.cache.json -> { packages = { <name> = { 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 '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
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 .. '/';
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
function api.version()
return _VERSION;
end
return api;
end
return createCcpm;