feat(ccpm): add package manager
This commit is contained in:
parent
600872389a
commit
9b49bb03d9
@ -33,7 +33,9 @@ Use `docs/README.md` as the entrypoint for CC:Tweaked, CraftOS-PC, Advanced Peri
|
|||||||
|
|
||||||
- `startup/servers.lua` starts `/programs`, the shell, and configured servers via `parallel.waitForAll`.
|
- `startup/servers.lua` starts `/programs`, the shell, and configured servers via `parallel.waitForAll`.
|
||||||
- Preserve `periphemu` guards used for CraftOS-PC emulation; see `docs/craftos_pc_glossary.md` for upstream emulator references.
|
- Preserve `periphemu` guards used for CraftOS-PC emulation; see `docs/craftos_pc_glossary.md` for upstream emulator references.
|
||||||
- `install.lua` downloads files listed in `LIST_FILES` from `master` by default, or from `next` with `--beta`; add shipped files there.
|
- TrapOS ships as packages, each described by `packages/<name>/ccpm.json` (`name`, `version`, `dependencies`, `files`, `autostart`); `packages/index.json` lists them. Source files stay in place — descriptors only reference them. To ship a new file, add it to the right package's `files` (and `autostart` if it is a server). See ADR-0010.
|
||||||
|
- `install.lua` reads `manifest.json` (`packages` list) from `master` by default, or `next` with `--beta`; it resolves descriptors, downloads files, and writes `/trapos/manifest.json` (aggregated OS state for motd/servers/upgrade) plus the ccpm state files. `--core` installs only `tos-core`.
|
||||||
|
- `ccpm` (in `tos-core`) is the package manager: `apis/libccpm.lua` is the testable core (factory with injectable `http`/`stateDir`/`installRoot`), `programs/ccpm.lua` the CLI. State: `/trapos/ccpm.json` (registries) and `/trapos/ccpm.lock.json` (installed packages). Never use the word "manifest" in ccpm — it is reserved for the OS manifest.
|
||||||
- Add new servers to `startup/servers.lua` as needed.
|
- Add new servers to `startup/servers.lua` as needed.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|||||||
6
Justfile
6
Justfile
@ -88,9 +88,11 @@ craftos *args: check-install
|
|||||||
argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
|
argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
|
||||||
fi
|
fi
|
||||||
argv+=(--mount-ro "/trapos=$repo")
|
argv+=(--mount-ro "/trapos=$repo")
|
||||||
while IFS= read -r dir; do
|
for dir in apis programs servers startup tests; do
|
||||||
|
if [ -d "$repo/$dir" ]; then
|
||||||
argv+=(--mount-ro "/$dir=$repo/$dir")
|
argv+=(--mount-ro "/$dir=$repo/$dir")
|
||||||
done < <(jq -r '.files[] | split("/")[0]' "$repo/manifest.json" | sort -u)
|
fi
|
||||||
|
done
|
||||||
exec craftos "${argv[@]}" "$@"
|
exec craftos "${argv[@]}" "$@"
|
||||||
|
|
||||||
# Human-only interactive REPL. LLM agents must not execute this command.
|
# Human-only interactive REPL. LLM agents must not execute this command.
|
||||||
|
|||||||
41
README.md
41
README.md
@ -4,10 +4,20 @@ A small in-game operating system for ComputerCraft / CC:Tweaked, built around a
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
Full install (all packages):
|
||||||
```
|
```
|
||||||
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/master/install.lua
|
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/master/install.lua
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Minimal install (only `tos-core`, i.e. just the package manager) so you can cherry-pick the rest yourself:
|
||||||
|
```
|
||||||
|
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/master/install.lua --core
|
||||||
|
```
|
||||||
|
```
|
||||||
|
> ccpm install tos-net
|
||||||
|
> ccpm install tos-ui
|
||||||
|
```
|
||||||
|
|
||||||
Install the beta branch (one-time opt-in, asks for confirmation):
|
Install the beta branch (one-time opt-in, asks for confirmation):
|
||||||
```
|
```
|
||||||
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/next/install.lua --beta
|
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/next/install.lua --beta
|
||||||
@ -17,9 +27,36 @@ Once a machine is on beta, `upgrade` keeps it on beta — `--beta` is not needed
|
|||||||
|
|
||||||
After install, every boot shows a colored MOTD with the installed version and branch (lime for stable, orange + `[BETA]` for beta).
|
After install, every boot shows a colored MOTD with the installed version and branch (lime for stable, orange + `[BETA]` for beta).
|
||||||
|
|
||||||
## Manifest
|
## Packages
|
||||||
|
|
||||||
The installed file list, version, autostart servers, and current branch all live in `manifest.json` at the repo root. A copy is persisted locally at `/trapos/manifest.json` after install, and drives `upgrade`, `startup/motd.lua`, and `startup/servers.lua`.
|
TrapOS is split into packages, each described by a `packages/<name>/ccpm.json`:
|
||||||
|
|
||||||
|
- `tos-core`: the package manager (`ccpm`), event loop, `upgrade`, `events`.
|
||||||
|
- `tos-test`: the test framework (`libtest`) and suite runner (`runtest`).
|
||||||
|
- `tos-boot`: the startup MOTD and autostart server launcher.
|
||||||
|
- `tos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`).
|
||||||
|
- `tos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
|
||||||
|
|
||||||
|
`manifest.json` at the repo root lists which packages a full install ships. The
|
||||||
|
bootstrap resolves their descriptors, downloads the files, and writes the aggregated
|
||||||
|
`/trapos/manifest.json` (version, branch, files, autostart) that drives `upgrade`,
|
||||||
|
`startup/motd.lua`, and `startup/servers.lua`.
|
||||||
|
|
||||||
|
## ccpm
|
||||||
|
|
||||||
|
`ccpm` is the TrapOS package manager (shipped in `tos-core`). It is configured at
|
||||||
|
install with a default registry (`guillaumearm/cc-libs`).
|
||||||
|
|
||||||
|
```
|
||||||
|
ccpm install <package> ccpm reinstall <package> ccpm uninstall <package>
|
||||||
|
ccpm ls ccpm search [term] ccpm info <package>
|
||||||
|
ccpm registry ls ccpm registry add <name> [--branch <b>] [--type github|http]
|
||||||
|
ccpm registry rm <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Registries default to GitHub (`owner/repo`); `http`/`https` base URLs are also
|
||||||
|
supported. State lives in `/trapos/ccpm.json` (registries) and `/trapos/ccpm.lock.json`
|
||||||
|
(installed packages). See [ADR-0010](docs/adrs/adr-0010-ccpm-package-manager.md).
|
||||||
|
|
||||||
## APIs
|
## APIs
|
||||||
- `/apis/eventloop`: a simple event loop API.
|
- `/apis/eventloop`: a simple event loop API.
|
||||||
|
|||||||
343
apis/libccpm.lua
Normal file
343
apis/libccpm.lua
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
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;
|
||||||
@ -17,3 +17,4 @@ Future ADRs can reuse the shape of the existing files when it is useful.
|
|||||||
- [`adr-0007-use-libtest-for-craftos-tests.md`](adr-0007-use-libtest-for-craftos-tests.md) - Use libtest for CraftOS tests.
|
- [`adr-0007-use-libtest-for-craftos-tests.md`](adr-0007-use-libtest-for-craftos-tests.md) - Use libtest for CraftOS tests.
|
||||||
- [`adr-0008-keep-tests-runnable-in-craftos-and-in-game.md`](adr-0008-keep-tests-runnable-in-craftos-and-in-game.md) - Keep tests runnable in CraftOS and in-game.
|
- [`adr-0008-keep-tests-runnable-in-craftos-and-in-game.md`](adr-0008-keep-tests-runnable-in-craftos-and-in-game.md) - Keep tests runnable in CraftOS and in-game.
|
||||||
- [`adr-0009-layered-test-timeouts.md`](adr-0009-layered-test-timeouts.md) - Layered test timeouts (libtest per-case + shell watchdog).
|
- [`adr-0009-layered-test-timeouts.md`](adr-0009-layered-test-timeouts.md) - Layered test timeouts (libtest per-case + shell watchdog).
|
||||||
|
- [`adr-0010-ccpm-package-manager.md`](adr-0010-ccpm-package-manager.md) - ccpm package manager (packages, registries, package-aware bootstrap).
|
||||||
|
|||||||
95
docs/adrs/adr-0010-ccpm-package-manager.md
Normal file
95
docs/adrs/adr-0010-ccpm-package-manager.md
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# ADR 0010: ccpm Package Manager
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-06-08
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0004 made installs manifest-driven: `install.lua` reads a flat `manifest.json`
|
||||||
|
file list from a branch and `wget`s every file. That is all-or-nothing. There is no
|
||||||
|
way to install just networking or just the UI, and no way to add or remove pieces of
|
||||||
|
the OS after the initial install.
|
||||||
|
|
||||||
|
We want a package manager, `ccpm` ("ComputerCraft Package Manager"), shipped as part
|
||||||
|
of the base OS, so a machine can `ccpm install tos-net`, `ccpm uninstall tos-ui`, and
|
||||||
|
manage where packages come from. TrapOS itself is **not** becoming a package for now;
|
||||||
|
the `wget run .../install.lua` bootstrap stays the recommended entry point.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Packages are descriptors over the existing tree
|
||||||
|
|
||||||
|
Source files stay where they are (`apis/`, `programs/`, `servers/`, `startup/`); their
|
||||||
|
install targets remain the same absolute CC paths, so `require` paths and the dev
|
||||||
|
mounts are unchanged. A package is a descriptor that *references* those files:
|
||||||
|
`packages/<name>/ccpm.json` with `{ name, version, description, dependencies, files,
|
||||||
|
autostart }`. `packages/index.json` lists the packages a registry offers (for
|
||||||
|
`ccpm search`). There is no `ccpm.json` at the repo root.
|
||||||
|
|
||||||
|
The split is finer-grained than the install examples imply:
|
||||||
|
|
||||||
|
| package | contents | deps |
|
||||||
|
|----------|-----------------------------------------------------------------|----------|
|
||||||
|
| tos-core | ccpm, libccpm, eventloop, upgrade, events | — |
|
||||||
|
| tos-test | libtest, runtest | tos-core |
|
||||||
|
| tos-boot | motd, servers (startup) | tos-core |
|
||||||
|
| tos-net | net, router, ping, ping-server | tos-core |
|
||||||
|
| tos-ui | libtui, tuidemo | tos-core |
|
||||||
|
|
||||||
|
### Two files for ccpm, "manifest" reserved for the OS
|
||||||
|
|
||||||
|
To avoid colliding with the OS `manifest.json`, ccpm never uses the word "manifest".
|
||||||
|
Local state lives under `/trapos`:
|
||||||
|
|
||||||
|
- `ccpm.json` — ordered registry list `{ registries = { { name, type, branch } } }`.
|
||||||
|
`type` is `github` (resolves to `raw.githubusercontent.com/<name>/<branch>/`) or
|
||||||
|
`http`/`https` (the `name` is a base URL).
|
||||||
|
- `ccpm.lock.json` — installed packages `{ packages = { <name> = { version, registry,
|
||||||
|
files, dependencies, autostart } } }`, used by `ls`, `uninstall`, and `reinstall`.
|
||||||
|
|
||||||
|
`apis/libccpm.lua` is the testable core (a factory; `http`/`stateDir`/`installRoot`
|
||||||
|
are injectable for tests). `programs/ccpm.lua` is a thin CLI over it.
|
||||||
|
|
||||||
|
### The bootstrap is package-aware
|
||||||
|
|
||||||
|
`manifest.json` now lists `packages` instead of `files`. `install.lua` resolves each
|
||||||
|
package descriptor (pulling dependencies), downloads the union of their files, and
|
||||||
|
writes:
|
||||||
|
|
||||||
|
- `/trapos/manifest.json` — the aggregated `{ name, version, branch, files, autostart }`
|
||||||
|
still consumed verbatim by `startup/motd.lua`, `startup/servers.lua`, and
|
||||||
|
`programs/upgrade.lua` (those three are unchanged);
|
||||||
|
- `/trapos/ccpm.lock.json` — so right after a fresh install `ccpm install tos-core`
|
||||||
|
correctly reports "already installed";
|
||||||
|
- `/trapos/ccpm.json` — seeding/refreshing the default `guillaumearm/cc-libs` registry
|
||||||
|
to track the install branch.
|
||||||
|
|
||||||
|
Two install paths follow:
|
||||||
|
|
||||||
|
- `wget run .../install.lua` — full OS (all packages in `manifest.packages`).
|
||||||
|
- `wget run .../install.lua --core` — only `tos-core` (i.e. just `ccpm`); the user then
|
||||||
|
cherry-picks with `ccpm install ...`.
|
||||||
|
|
||||||
|
On a subsequent `upgrade`, `install.lua` prefers the existing lockfile's package set
|
||||||
|
over `manifest.packages`, so a cherry-picked machine upgrades only what it actually has.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The repo gains a `packages/` descriptor tree; the flat source layout is untouched.
|
||||||
|
- `just craftos` no longer derives mounts from `manifest.json .files` (it is now
|
||||||
|
`.packages`); it mounts a fixed list of top-level dirs instead. `just test` was
|
||||||
|
already on fixed mounts and is unaffected.
|
||||||
|
- ccpm logic is covered by `tests/ccpm.lua` (URL resolution, dependency ordering,
|
||||||
|
cycle/missing detection, already-installed, registry CRUD, uninstall dependency
|
||||||
|
guard) with an injected `http` stub — no network in tests.
|
||||||
|
|
||||||
|
## Future Work
|
||||||
|
|
||||||
|
- Version ranges / `ccpm update` (today a single pinned version per package).
|
||||||
|
- Making TrapOS self-update through ccpm rather than the `wget run` bootstrap.
|
||||||
|
- http/https registries beyond a plain base URL (auth, caching).
|
||||||
179
install.lua
179
install.lua
@ -1,20 +1,24 @@
|
|||||||
local _VERSION = '3.0.0';
|
local _VERSION = '4.0.0';
|
||||||
|
|
||||||
local REPO_BASE = 'https://raw.githubusercontent.com/guillaumearm/cc-libs/';
|
local REPO_BASE = 'https://raw.githubusercontent.com/guillaumearm/cc-libs/';
|
||||||
local LOCAL_STATE_DIR = '/trapos';
|
local LOCAL_STATE_DIR = '/trapos';
|
||||||
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
||||||
|
local LOCAL_CONFIG_PATH = '/trapos/ccpm.json';
|
||||||
|
local LOCAL_LOCK_PATH = '/trapos/ccpm.lock.json';
|
||||||
|
local DEFAULT_REGISTRY_NAME = 'guillaumearm/cc-libs';
|
||||||
|
|
||||||
local function printUsage()
|
local function printUsage()
|
||||||
print('install usage:');
|
print('install usage:');
|
||||||
print();
|
print();
|
||||||
print('\t\twget run <install-url>');
|
print('\t\twget run <install-url>');
|
||||||
|
print('\t\twget run <install-url> --core');
|
||||||
print('\t\twget run <install-url> --beta');
|
print('\t\twget run <install-url> --beta');
|
||||||
print('\t\twget run <install-url> --stable');
|
print('\t\twget run <install-url> --stable');
|
||||||
end
|
end
|
||||||
|
|
||||||
local function readLocalManifest()
|
local function readJsonFile(path)
|
||||||
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
|
if not fs.exists(path) then return nil end
|
||||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
|
local f = fs.open(path, 'r');
|
||||||
if not f then return nil end
|
if not f then return nil end
|
||||||
local data = f.readAll();
|
local data = f.readAll();
|
||||||
f.close();
|
f.close();
|
||||||
@ -22,11 +26,11 @@ local function readLocalManifest()
|
|||||||
return textutils.unserializeJSON(data);
|
return textutils.unserializeJSON(data);
|
||||||
end
|
end
|
||||||
|
|
||||||
local function writeLocalManifest(manifest)
|
local function writeJsonFile(path, value)
|
||||||
fs.makeDir(LOCAL_STATE_DIR);
|
fs.makeDir(LOCAL_STATE_DIR);
|
||||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'w');
|
local f = fs.open(path, 'w');
|
||||||
if not f then return false end
|
if not f then return false end
|
||||||
f.write(textutils.serializeJSON(manifest));
|
f.write(textutils.serializeJSON(value));
|
||||||
f.close();
|
f.close();
|
||||||
return true;
|
return true;
|
||||||
end
|
end
|
||||||
@ -42,8 +46,7 @@ local function confirmBeta()
|
|||||||
return answer == 'y' or answer == 'yes';
|
return answer == 'y' or answer == 'yes';
|
||||||
end
|
end
|
||||||
|
|
||||||
local function fetchManifest(branch)
|
local function fetchJson(url)
|
||||||
local url = REPO_BASE .. branch .. '/manifest.json';
|
|
||||||
local response = http.get(url);
|
local response = http.get(url);
|
||||||
if not response then return nil end
|
if not response then return nil end
|
||||||
local body = response.readAll();
|
local body = response.readAll();
|
||||||
@ -52,30 +55,88 @@ local function fetchManifest(branch)
|
|||||||
return textutils.unserializeJSON(body);
|
return textutils.unserializeJSON(body);
|
||||||
end
|
end
|
||||||
|
|
||||||
local command = ...;
|
local function fetchManifest(branch)
|
||||||
local forceBeta = false;
|
return fetchJson(REPO_BASE .. branch .. '/manifest.json');
|
||||||
local forceStable = false;
|
end
|
||||||
|
|
||||||
if command == 'version' or command == '-version' or command == '--version' then
|
local function fetchDescriptor(branch, pkg)
|
||||||
|
return fetchJson(REPO_BASE .. branch .. '/packages/' .. pkg .. '/ccpm.json');
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve a list of package names + their dependencies into install order
|
||||||
|
-- (deps first). Returns an ordered list of descriptors or nil, err.
|
||||||
|
local function resolvePackages(branch, names)
|
||||||
|
local ordered = {};
|
||||||
|
local state = {}; -- name -> 'visiting' | 'done'
|
||||||
|
|
||||||
|
local function visit(name)
|
||||||
|
if state[name] == 'done' then return true end
|
||||||
|
if state[name] == 'visiting' then
|
||||||
|
return false, 'dependency cycle detected at ' .. name;
|
||||||
|
end
|
||||||
|
state[name] = 'visiting';
|
||||||
|
local desc = fetchDescriptor(branch, name);
|
||||||
|
if not desc then
|
||||||
|
return false, 'package not found: ' .. name;
|
||||||
|
end
|
||||||
|
for _, dep in ipairs(desc.dependencies or {}) do
|
||||||
|
local ok, err = visit(dep);
|
||||||
|
if not ok then return false, err end
|
||||||
|
end
|
||||||
|
state[name] = 'done';
|
||||||
|
ordered[#ordered + 1] = desc;
|
||||||
|
return true;
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
local ok, err = visit(name);
|
||||||
|
if not ok then return nil, err end
|
||||||
|
end
|
||||||
|
return ordered;
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Seed/refresh the default ccpm registry so it tracks the install branch.
|
||||||
|
local function seedCcpmConfig(branch)
|
||||||
|
local cfg = readJsonFile(LOCAL_CONFIG_PATH) or { registries = {} };
|
||||||
|
cfg.registries = cfg.registries or {};
|
||||||
|
local found = false;
|
||||||
|
for _, r in ipairs(cfg.registries) do
|
||||||
|
if r.name == DEFAULT_REGISTRY_NAME then
|
||||||
|
r.type = 'github';
|
||||||
|
r.branch = branch;
|
||||||
|
found = true;
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not found then
|
||||||
|
table.insert(cfg.registries, 1, { name = DEFAULT_REGISTRY_NAME, type = 'github', branch = branch });
|
||||||
|
end
|
||||||
|
writeJsonFile(LOCAL_CONFIG_PATH, cfg);
|
||||||
|
end
|
||||||
|
|
||||||
|
local rawArgs = table.pack(...);
|
||||||
|
local forceBeta, forceStable, forceCore = false, false, false;
|
||||||
|
|
||||||
|
for i = 1, rawArgs.n do
|
||||||
|
local a = rawArgs[i];
|
||||||
|
if a == 'version' or a == '-version' or a == '--version' then
|
||||||
print('install v' .. _VERSION);
|
print('install v' .. _VERSION);
|
||||||
return;
|
return;
|
||||||
end
|
elseif a == 'help' or a == '-help' or a == '--help' then
|
||||||
|
|
||||||
if command == 'help' or command == '-help' or command == '--help' then
|
|
||||||
printUsage();
|
printUsage();
|
||||||
return;
|
return;
|
||||||
end
|
elseif a == '--beta' or a == '-beta' then
|
||||||
|
|
||||||
if command == '--beta' or command == '-beta' then
|
|
||||||
forceBeta = true;
|
forceBeta = true;
|
||||||
elseif command == '--stable' or command == '-stable' then
|
elseif a == '--stable' or a == '-stable' then
|
||||||
forceStable = true;
|
forceStable = true;
|
||||||
elseif command ~= nil and command ~= '' then
|
elseif a == '--core' or a == '-core' then
|
||||||
|
forceCore = true;
|
||||||
|
elseif a ~= nil and a ~= '' then
|
||||||
printUsage();
|
printUsage();
|
||||||
return;
|
return;
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local localManifest = readLocalManifest();
|
local localManifest = readJsonFile(LOCAL_MANIFEST_PATH);
|
||||||
local localBranch = localManifest and localManifest.branch or nil;
|
local localBranch = localManifest and localManifest.branch or nil;
|
||||||
local branch;
|
local branch;
|
||||||
|
|
||||||
@ -95,13 +156,39 @@ end
|
|||||||
|
|
||||||
print('Fetching manifest from branch: ' .. branch);
|
print('Fetching manifest from branch: ' .. branch);
|
||||||
local manifest = fetchManifest(branch);
|
local manifest = fetchManifest(branch);
|
||||||
if not manifest or type(manifest.files) ~= 'table' then
|
if not manifest then
|
||||||
print('Failed to fetch or parse manifest.json from ' .. branch);
|
print('Failed to fetch or parse manifest.json from ' .. branch);
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The persisted branch reflects the actually-used branch, not the manifest default.
|
-- Decide which packages to install:
|
||||||
manifest.branch = branch;
|
-- --core -> only tos-core (the package manager bootstrap)
|
||||||
|
-- existing lockfile -> whatever is already installed (upgrade in place)
|
||||||
|
-- otherwise -> the full set listed in manifest.packages
|
||||||
|
local requested;
|
||||||
|
if forceCore then
|
||||||
|
requested = { 'tos-core' };
|
||||||
|
else
|
||||||
|
local lock = readJsonFile(LOCAL_LOCK_PATH);
|
||||||
|
if lock and type(lock.packages) == 'table' and next(lock.packages) then
|
||||||
|
requested = {};
|
||||||
|
for name in pairs(lock.packages) do requested[#requested + 1] = name end
|
||||||
|
table.sort(requested);
|
||||||
|
else
|
||||||
|
requested = manifest.packages;
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(requested) ~= 'table' or #requested == 0 then
|
||||||
|
print('No packages to install (manifest.packages missing?)');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
local resolved, resolveErr = resolvePackages(branch, requested);
|
||||||
|
if not resolved then
|
||||||
|
print('Failed to resolve packages: ' .. resolveErr);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
local REPO_PREFIX = REPO_BASE .. branch .. '/';
|
local REPO_PREFIX = REPO_BASE .. branch .. '/';
|
||||||
|
|
||||||
@ -125,17 +212,51 @@ fs.makeDir('/startup');
|
|||||||
fs.makeDir('/servers');
|
fs.makeDir('/servers');
|
||||||
fs.makeDir(LOCAL_STATE_DIR);
|
fs.makeDir(LOCAL_STATE_DIR);
|
||||||
|
|
||||||
for _, filePath in ipairs(manifest.files) do
|
local allFiles = {};
|
||||||
|
local seenFile = {};
|
||||||
|
local autostart = {};
|
||||||
|
local seenAuto = {};
|
||||||
|
local lockPackages = {};
|
||||||
|
|
||||||
|
for _, desc in ipairs(resolved) do
|
||||||
|
for _, filePath in ipairs(desc.files or {}) do
|
||||||
|
if not seenFile[filePath] then
|
||||||
|
seenFile[filePath] = true;
|
||||||
|
allFiles[#allFiles + 1] = filePath;
|
||||||
fs.delete(filePath);
|
fs.delete(filePath);
|
||||||
shell.execute('wget', REPO_PREFIX .. filePath, filePath);
|
shell.execute('wget', REPO_PREFIX .. filePath, filePath);
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, srv in ipairs(desc.autostart or {}) do
|
||||||
|
if not seenAuto[srv] then
|
||||||
|
seenAuto[srv] = true;
|
||||||
|
autostart[#autostart + 1] = srv;
|
||||||
|
end
|
||||||
|
end
|
||||||
|
lockPackages[desc.name] = {
|
||||||
|
version = desc.version,
|
||||||
|
registry = DEFAULT_REGISTRY_NAME,
|
||||||
|
files = desc.files or {},
|
||||||
|
dependencies = desc.dependencies or {},
|
||||||
|
autostart = desc.autostart or {},
|
||||||
|
};
|
||||||
end
|
end
|
||||||
|
|
||||||
if not writeLocalManifest(manifest) then
|
-- Aggregated OS state for motd/servers/upgrade (unchanged consumers).
|
||||||
print('Warning: failed to write local manifest');
|
writeJsonFile(LOCAL_MANIFEST_PATH, {
|
||||||
end
|
name = manifest.name or 'TrapOS',
|
||||||
|
version = manifest.version or '?',
|
||||||
|
branch = branch,
|
||||||
|
files = allFiles,
|
||||||
|
autostart = autostart,
|
||||||
|
});
|
||||||
|
|
||||||
|
writeJsonFile(LOCAL_LOCK_PATH, { packages = lockPackages });
|
||||||
|
seedCcpmConfig(branch);
|
||||||
|
|
||||||
print();
|
print();
|
||||||
print('=> TrapOS v' .. (manifest.version or '?') .. ' installed (branch: ' .. branch .. ')');
|
print('=> TrapOS v' .. (manifest.version or '?') .. ' installed (branch: ' .. branch .. ')');
|
||||||
|
print('=> Packages: ' .. table.concat(requested, ', '));
|
||||||
print('=> Execute startup/servers.lua');
|
print('=> Execute startup/servers.lua');
|
||||||
shell.execute('/startup/servers.lua');
|
shell.execute('/startup/servers.lua');
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"branch": "next",
|
"branch": "next",
|
||||||
"files": [
|
"packages": [
|
||||||
"startup/motd.lua",
|
"tos-core",
|
||||||
"startup/servers.lua",
|
"tos-test",
|
||||||
"servers/ping-server.lua",
|
"tos-boot",
|
||||||
"programs/router.lua",
|
"tos-net",
|
||||||
"programs/events.lua",
|
"tos-ui"
|
||||||
"programs/ping.lua",
|
|
||||||
"programs/runtest.lua",
|
|
||||||
"programs/tuidemo.lua",
|
|
||||||
"programs/upgrade.lua",
|
|
||||||
"apis/net.lua",
|
|
||||||
"apis/eventloop.lua",
|
|
||||||
"apis/libtest.lua",
|
|
||||||
"apis/libtui.lua"
|
|
||||||
],
|
|
||||||
"autostart": [
|
|
||||||
"servers/ping-server"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
9
packages/index.json
Normal file
9
packages/index.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"packages": {
|
||||||
|
"tos-core": "0.1.0",
|
||||||
|
"tos-test": "0.1.0",
|
||||||
|
"tos-boot": "0.1.0",
|
||||||
|
"tos-net": "0.1.0",
|
||||||
|
"tos-ui": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/tos-boot/ccpm.json
Normal file
11
packages/tos-boot/ccpm.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "tos-boot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TrapOS boot: startup MOTD and autostart server launcher",
|
||||||
|
"dependencies": ["tos-core"],
|
||||||
|
"files": [
|
||||||
|
"startup/motd.lua",
|
||||||
|
"startup/servers.lua"
|
||||||
|
],
|
||||||
|
"autostart": []
|
||||||
|
}
|
||||||
14
packages/tos-core/ccpm.json
Normal file
14
packages/tos-core/ccpm.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "tos-core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TrapOS base: package manager, event loop, upgrade and event tools",
|
||||||
|
"dependencies": [],
|
||||||
|
"files": [
|
||||||
|
"apis/eventloop.lua",
|
||||||
|
"apis/libccpm.lua",
|
||||||
|
"programs/ccpm.lua",
|
||||||
|
"programs/upgrade.lua",
|
||||||
|
"programs/events.lua"
|
||||||
|
],
|
||||||
|
"autostart": []
|
||||||
|
}
|
||||||
13
packages/tos-net/ccpm.json
Normal file
13
packages/tos-net/ccpm.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "tos-net",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TrapOS networking: routed modem messaging, router, ping",
|
||||||
|
"dependencies": ["tos-core"],
|
||||||
|
"files": [
|
||||||
|
"apis/net.lua",
|
||||||
|
"programs/router.lua",
|
||||||
|
"programs/ping.lua",
|
||||||
|
"servers/ping-server.lua"
|
||||||
|
],
|
||||||
|
"autostart": ["servers/ping-server"]
|
||||||
|
}
|
||||||
11
packages/tos-test/ccpm.json
Normal file
11
packages/tos-test/ccpm.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "tos-test",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TrapOS test framework and CraftOS-PC suite runner",
|
||||||
|
"dependencies": ["tos-core"],
|
||||||
|
"files": [
|
||||||
|
"apis/libtest.lua",
|
||||||
|
"programs/runtest.lua"
|
||||||
|
],
|
||||||
|
"autostart": []
|
||||||
|
}
|
||||||
11
packages/tos-ui/ccpm.json
Normal file
11
packages/tos-ui/ccpm.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "tos-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TrapOS terminal UI toolkit and demo",
|
||||||
|
"dependencies": ["tos-core"],
|
||||||
|
"files": [
|
||||||
|
"apis/libtui.lua",
|
||||||
|
"programs/tuidemo.lua"
|
||||||
|
],
|
||||||
|
"autostart": []
|
||||||
|
}
|
||||||
169
programs/ccpm.lua
Normal file
169
programs/ccpm.lua
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
local _VERSION = '0.1.0';
|
||||||
|
|
||||||
|
local createCcpm = require('/apis/libccpm');
|
||||||
|
|
||||||
|
local args = table.pack(...);
|
||||||
|
local command = args[1];
|
||||||
|
|
||||||
|
local function printUsage()
|
||||||
|
print('ccpm usage:');
|
||||||
|
print();
|
||||||
|
print('\t\tccpm install <package>');
|
||||||
|
print('\t\tccpm reinstall <package>');
|
||||||
|
print('\t\tccpm uninstall <package>');
|
||||||
|
print('\t\tccpm ls');
|
||||||
|
print('\t\tccpm search [term]');
|
||||||
|
print('\t\tccpm info <package>');
|
||||||
|
print('\t\tccpm registry ls');
|
||||||
|
print('\t\tccpm registry add <name> [--branch <b>] [--type github|http]');
|
||||||
|
print('\t\tccpm registry rm <name>');
|
||||||
|
print('\t\tccpm version');
|
||||||
|
print('\t\tccpm help');
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'version' or command == '-version' or command == '--version' then
|
||||||
|
print('ccpm v' .. _VERSION);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == nil or command == '' or command == 'help' or command == '-help' or command == '--help' then
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
local ccpm = createCcpm();
|
||||||
|
|
||||||
|
local function logLine(msg)
|
||||||
|
print(msg);
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'install' or command == 'reinstall' then
|
||||||
|
local pkg = args[2];
|
||||||
|
if not pkg then printUsage(); return; end
|
||||||
|
local ok, result = ccpm.install(pkg, { force = command == 'reinstall', log = logLine });
|
||||||
|
if not ok then
|
||||||
|
print(result);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
print('=> ' .. pkg .. ' installed.');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'uninstall' or command == 'remove' or command == 'rm' then
|
||||||
|
local pkg = args[2];
|
||||||
|
if not pkg then printUsage(); return; end
|
||||||
|
local ok, err = ccpm.uninstall(pkg, { log = logLine });
|
||||||
|
if not ok then
|
||||||
|
print(err);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
print('=> ' .. pkg .. ' uninstalled.');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'ls' or command == 'list' then
|
||||||
|
local packages = ccpm.list();
|
||||||
|
local names = {};
|
||||||
|
for name in pairs(packages) do names[#names + 1] = name; end
|
||||||
|
table.sort(names);
|
||||||
|
if #names == 0 then
|
||||||
|
print('No packages installed.');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
print(name .. ' v' .. tostring(packages[name].version or '?'));
|
||||||
|
end
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'search' then
|
||||||
|
local results = ccpm.search(args[2]);
|
||||||
|
if #results == 0 then
|
||||||
|
print('No packages found.');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
for _, r in ipairs(results) do
|
||||||
|
print(r.name .. ' v' .. tostring(r.version) .. ' (' .. r.registry .. ')');
|
||||||
|
end
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'info' then
|
||||||
|
local pkg = args[2];
|
||||||
|
if not pkg then printUsage(); return; end
|
||||||
|
local desc = ccpm.info(pkg);
|
||||||
|
if not desc then
|
||||||
|
print('package not found: ' .. pkg);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
print(desc.name .. ' v' .. tostring(desc.version or '?'));
|
||||||
|
if desc.description then print(desc.description); end
|
||||||
|
if desc.dependencies and #desc.dependencies > 0 then
|
||||||
|
print('dependencies: ' .. table.concat(desc.dependencies, ', '));
|
||||||
|
end
|
||||||
|
print('files:');
|
||||||
|
for _, f in ipairs(desc.files or {}) do print(' ' .. f); end
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'registry' then
|
||||||
|
local sub = args[2];
|
||||||
|
|
||||||
|
if sub == nil or sub == 'ls' or sub == 'list' then
|
||||||
|
local registries = ccpm.listRegistries();
|
||||||
|
if #registries == 0 then
|
||||||
|
print('No registries configured.');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
for _, r in ipairs(registries) do
|
||||||
|
if r.type == 'github' then
|
||||||
|
print(r.name .. ' (github:' .. tostring(r.branch or 'master') .. ')');
|
||||||
|
else
|
||||||
|
print(r.name .. ' (' .. tostring(r.type or 'http') .. ')');
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if sub == 'add' then
|
||||||
|
local name = args[3];
|
||||||
|
if not name then printUsage(); return; end
|
||||||
|
local branch, rtype;
|
||||||
|
local i = 4;
|
||||||
|
while i <= args.n do
|
||||||
|
local a = args[i];
|
||||||
|
if a == '--branch' then
|
||||||
|
branch = args[i + 1];
|
||||||
|
i = i + 1;
|
||||||
|
elseif a == '--type' then
|
||||||
|
rtype = args[i + 1];
|
||||||
|
i = i + 1;
|
||||||
|
end
|
||||||
|
i = i + 1;
|
||||||
|
end
|
||||||
|
local ok, err = ccpm.addRegistry(name, { branch = branch, type = rtype });
|
||||||
|
if not ok then
|
||||||
|
print(err);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
print('=> registry added: ' .. name);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if sub == 'rm' or sub == 'remove' then
|
||||||
|
local name = args[3];
|
||||||
|
if not name then printUsage(); return; end
|
||||||
|
local ok, err = ccpm.removeRegistry(name);
|
||||||
|
if not ok then
|
||||||
|
print(err);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
print('=> registry removed: ' .. name);
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
printUsage();
|
||||||
182
tests/ccpm.lua
Normal file
182
tests/ccpm.lua
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
local createLibTest = require('/apis/libtest');
|
||||||
|
local createCcpm = require('/apis/libccpm');
|
||||||
|
|
||||||
|
local testlib = createLibTest({ ... });
|
||||||
|
|
||||||
|
local counter = 0;
|
||||||
|
|
||||||
|
-- Fresh, isolated state + install sandbox per case (never touches real /trapos).
|
||||||
|
local function freshDirs()
|
||||||
|
counter = counter + 1;
|
||||||
|
local stateDir = '/ccpm-test/state-' .. counter;
|
||||||
|
local installRoot = '/ccpm-test/root-' .. counter;
|
||||||
|
fs.delete(stateDir);
|
||||||
|
fs.delete(installRoot);
|
||||||
|
return stateDir, installRoot;
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Minimal stub of the CC `http` API backed by a url -> body map.
|
||||||
|
local function fakeHttp(routes)
|
||||||
|
return {
|
||||||
|
get = function(url)
|
||||||
|
local body = routes[url];
|
||||||
|
if not body then return nil; end
|
||||||
|
return {
|
||||||
|
readAll = function() return body; end,
|
||||||
|
close = function() end,
|
||||||
|
};
|
||||||
|
end,
|
||||||
|
};
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ghBase(name, branch)
|
||||||
|
return 'https://raw.githubusercontent.com/' .. name .. '/' .. branch .. '/';
|
||||||
|
end
|
||||||
|
|
||||||
|
testlib.test('registryBaseUrl resolves a github branch', function()
|
||||||
|
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||||
|
testlib.assertEquals(
|
||||||
|
ccpm.registryBaseUrl({ name = 'guillaumearm/cc-libs', type = 'github', branch = 'next' }),
|
||||||
|
'https://raw.githubusercontent.com/guillaumearm/cc-libs/next/'
|
||||||
|
);
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('registry urls resolve an http base', function()
|
||||||
|
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||||
|
testlib.assertEquals(
|
||||||
|
ccpm.registryBaseUrl({ name = 'http://example.com/repo', type = 'http' }),
|
||||||
|
'http://example.com/repo/'
|
||||||
|
);
|
||||||
|
testlib.assertEquals(
|
||||||
|
ccpm.descriptorUrl({ name = 'http://example.com/repo/', type = 'http' }, 'tos-net'),
|
||||||
|
'http://example.com/repo/packages/tos-net/ccpm.json'
|
||||||
|
);
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('resolve orders dependencies before dependents', function()
|
||||||
|
local base = ghBase('me/repo', 'master');
|
||||||
|
local routes = {
|
||||||
|
[base .. 'packages/a/ccpm.json'] = textutils.serializeJSON({ name = 'a', version = '1', dependencies = { 'b', 'c' }, files = {} }),
|
||||||
|
[base .. 'packages/b/ccpm.json'] = textutils.serializeJSON({ name = 'b', version = '1', dependencies = { 'c' }, files = {} }),
|
||||||
|
[base .. 'packages/c/ccpm.json'] = textutils.serializeJSON({ name = 'c', version = '1', dependencies = {}, files = {} }),
|
||||||
|
};
|
||||||
|
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) });
|
||||||
|
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||||
|
local ordered, err = ccpm.resolve('a');
|
||||||
|
testlib.assertTrue(ordered, tostring(err));
|
||||||
|
testlib.assertEquals(#ordered, 3);
|
||||||
|
testlib.assertEquals(ordered[1].name, 'c');
|
||||||
|
testlib.assertEquals(ordered[2].name, 'b');
|
||||||
|
testlib.assertEquals(ordered[3].name, 'a');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('resolve reports a missing package', function()
|
||||||
|
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp({}) });
|
||||||
|
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||||
|
local ordered, err = ccpm.resolve('ghost');
|
||||||
|
testlib.assertTrue(not ordered);
|
||||||
|
testlib.assertTrue(string.find(err, 'not found', 1, true));
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('resolve detects a dependency cycle', function()
|
||||||
|
local base = ghBase('me/repo', 'master');
|
||||||
|
local routes = {
|
||||||
|
[base .. 'packages/a/ccpm.json'] = textutils.serializeJSON({ name = 'a', version = '1', dependencies = { 'b' } }),
|
||||||
|
[base .. 'packages/b/ccpm.json'] = textutils.serializeJSON({ name = 'b', version = '1', dependencies = { 'a' } }),
|
||||||
|
};
|
||||||
|
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) });
|
||||||
|
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||||
|
local ordered, err = ccpm.resolve('a');
|
||||||
|
testlib.assertTrue(not ordered);
|
||||||
|
testlib.assertTrue(string.find(err, 'cycle', 1, true));
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('install rejects an already-installed package', function()
|
||||||
|
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||||
|
ccpm.writeLock({ packages = { foo = { version = '1', files = {} } } });
|
||||||
|
local ok, msg = ccpm.install('foo', {});
|
||||||
|
testlib.assertTrue(not ok);
|
||||||
|
testlib.assertTrue(string.find(msg, 'already installed', 1, true));
|
||||||
|
testlib.assertTrue(string.find(msg, 'reinstall foo', 1, true));
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('install downloads files and records the lock', function()
|
||||||
|
local base = ghBase('me/repo', 'master');
|
||||||
|
local routes = {
|
||||||
|
[base .. 'packages/tos-core/ccpm.json'] = textutils.serializeJSON({ name = 'tos-core', version = '1', dependencies = {}, files = { 'apis/eventloop.lua' } }),
|
||||||
|
[base .. 'packages/tos-net/ccpm.json'] = textutils.serializeJSON({ name = 'tos-net', version = '1', dependencies = { 'tos-core' }, files = { 'apis/net.lua' } }),
|
||||||
|
[base .. 'apis/eventloop.lua'] = 'eventloop-body',
|
||||||
|
[base .. 'apis/net.lua'] = 'net-body',
|
||||||
|
};
|
||||||
|
local sd, root = freshDirs();
|
||||||
|
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
|
||||||
|
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||||
|
|
||||||
|
local ok = ccpm.install('tos-net', {});
|
||||||
|
testlib.assertTrue(ok);
|
||||||
|
testlib.assertTrue(fs.exists(root .. '/apis/net.lua'));
|
||||||
|
testlib.assertTrue(fs.exists(root .. '/apis/eventloop.lua'));
|
||||||
|
|
||||||
|
local f = fs.open(root .. '/apis/net.lua', 'r');
|
||||||
|
local body = f.readAll();
|
||||||
|
f.close();
|
||||||
|
testlib.assertEquals(body, 'net-body');
|
||||||
|
|
||||||
|
local lock = ccpm.readLock();
|
||||||
|
testlib.assertTrue(lock.packages['tos-net']);
|
||||||
|
testlib.assertTrue(lock.packages['tos-core']);
|
||||||
|
testlib.assertEquals(lock.packages['tos-net'].registry, 'me/repo');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('registry add and remove round-trip', function()
|
||||||
|
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||||
|
ccpm.writeConfig({ registries = {} });
|
||||||
|
testlib.assertTrue(ccpm.addRegistry('foo/bar', { branch = 'next' }));
|
||||||
|
|
||||||
|
local regs = ccpm.listRegistries();
|
||||||
|
testlib.assertEquals(#regs, 1);
|
||||||
|
testlib.assertEquals(regs[1].name, 'foo/bar');
|
||||||
|
testlib.assertEquals(regs[1].branch, 'next');
|
||||||
|
|
||||||
|
local dupOk = ccpm.addRegistry('foo/bar', {});
|
||||||
|
testlib.assertTrue(not dupOk);
|
||||||
|
|
||||||
|
testlib.assertTrue(ccpm.removeRegistry('foo/bar'));
|
||||||
|
testlib.assertEquals(#ccpm.listRegistries(), 0);
|
||||||
|
|
||||||
|
local rmOk = ccpm.removeRegistry('nope');
|
||||||
|
testlib.assertTrue(not rmOk);
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('uninstall refuses a package with dependents', function()
|
||||||
|
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||||
|
ccpm.writeLock({ packages = {
|
||||||
|
['tos-core'] = { version = '1', files = {}, dependencies = {} },
|
||||||
|
['tos-net'] = { version = '1', files = {}, dependencies = { 'tos-core' } },
|
||||||
|
} });
|
||||||
|
local ok, err = ccpm.uninstall('tos-core', {});
|
||||||
|
testlib.assertTrue(not ok);
|
||||||
|
testlib.assertTrue(string.find(err, 'required by', 1, true));
|
||||||
|
testlib.assertTrue(string.find(err, 'tos-net', 1, true));
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('uninstall removes a leaf package and its files', function()
|
||||||
|
local sd, root = freshDirs();
|
||||||
|
fs.makeDir(root .. '/apis');
|
||||||
|
local f = fs.open(root .. '/apis/net.lua', 'w');
|
||||||
|
f.write('x');
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
local ccpm = createCcpm({ stateDir = sd, installRoot = root });
|
||||||
|
ccpm.writeLock({ packages = {
|
||||||
|
['tos-core'] = { version = '1', files = {}, dependencies = {} },
|
||||||
|
['tos-net'] = { version = '1', files = { 'apis/net.lua' }, dependencies = { 'tos-core' } },
|
||||||
|
} });
|
||||||
|
|
||||||
|
testlib.assertTrue(ccpm.uninstall('tos-net', {}));
|
||||||
|
testlib.assertTrue(not fs.exists(root .. '/apis/net.lua'));
|
||||||
|
testlib.assertTrue(ccpm.readLock().packages['tos-net'] == nil);
|
||||||
|
testlib.assertTrue(ccpm.readLock().packages['tos-core'] ~= nil);
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.run();
|
||||||
Loading…
Reference in New Issue
Block a user