344 lines
9.9 KiB
Lua
344 lines
9.9 KiB
Lua
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 = { <name> = { 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;
|